Lab #6: Webové rozhranie zariadenia

o prístupovom bode, webovom rámci Microdot, šablónach a konfigurácii zariadenia cez webové rozhranie

Motivácia

Ciele

  1. naučiť sa vytvoriť z chytrého zariadenia prístupový bod

  2. naučiť sa vytvoriť webové rozhranie pomocou rámca Microdot a šablón

  3. naučiť sa overiť konfiguráciu zariadenia pred jeho uvedením do prevádzky

  4. naučiť sa ochrániť prístup k nastaveniam heslom

Postup

Prístupový bod

Pri prvom spustení zariadenie nemá žiadnu konfiguráciu, takže sa nevie pripojiť k žiadnej WiFi sieti. WiFi modul preto prejde do režimu prístupového bodu (z angl. access point, AP) a vytvorí vlastnú WiFi sieť, ku ktorej sa používateľ pripojí telefónom alebo počítačom.

V tomto kroku vytvoríme nové zariadenie, ktoré bude reprezentovať práve prístupový bod. Na prácu s ním vytvoríme aj samostatný príkaz pre servisný terminál.

Prácu s prístupovým bodom opisuje kapitola Práca s WiFi modulom.

Task

Vytvorte triedu zariadenia, ktoré bude reprezentovať prístupový bod WiFi modulu.

Trieda network.WLAN pracuje s jedným z dvoch sieťových rozhraní WiFi modulu, ktoré určíte konštantou pri vytvorení objektu. Trieda CYW43439, ktorú už máte, používa rozhranie network.STA_IF na pripojenie k existujúcej sieti. Prístupový bod bude používať rozhranie network.AP_IF, cez ktoré zariadenie vytvorí vlastnú sieť. Rozhrania môžu byť zapnuté naraz.

O triede bude platiť nasledovné:

  • jej implementácia sa bude nachádzať v module cyw43439_ap.py,
  • názov triedy bude CYW43439AP,
  • názov zariadenia bude "CYW43439 AP",
  • opis zariadenia bude "Access point of the built-in WiFi module (Raspberry Pi Pico 2 W)",
  • bude implementovať mixin NetworkMixin a bude potomkom triedy BaseDevice.

Zariadenie zaregistrujte v správcovi zariadení pod aliasom ALIAS_AP.

Task

V module commands/ap.py vytvorte triedu AP, ktorá bude reprezentovať príkaz ap.

O tejto triede bude platiť:

  • bude potomkom triedy BaseCommand,
  • názov príkazu bude "ap",
  • opis príkazu bude "Manage access point.",
  • prvým parametrom príkazu bude názov podpríkazu (start, stop alebo stat),
  • ak príkaz nedostane žiadny parameter alebo dostane neznámy podpríkaz, vypíše správu "Error: Wrong usage.".

Jednotlivé podpríkazy implementujte ako operácie príkazu .start(), .stop() a .stat(), pretože ich neskôr použije aj stav Configuration. Operácia .stat() vráti slovník so stavom prístupového bodu. Ak je zapnutý, bude obsahovať aj názov siete, zabezpečenie, IP adresu a počet pripojených zariadení, ktorý zistíte pomocou .status('stations').

Príkaz zaregistrujte rovnako ako ostatné príkazy.

Task

Rozšírte používateľské nastavenia o heslo prístupového bodu.

Heslo bude v novej triede AP v členskej premennej password s predvolenou hodnotou None. V triede Settings bude trieda dostupná v členskej premennej ap. Vytvorte aj validátor, ktorý overí, že heslo má aspoň 8 znakov, pretože kratšie heslo protokol WPA2 nepovoľuje.

Task

Vytvorte v príkaze ap operácie .ssid() a .password(), ktoré vrátia názov a heslo siete zariadenia.

Názov siete bude v tvare <device_type>-<device_id>, napr. thsensor-ab123cd, aby sa zariadenia v jednej miestnosti dali rozlíšiť. Za <device_id> použite konštantu DEVICE_ID z modulu identity.py.

Operácia .password() vráti heslo z nastavení. Ak nastavenia ešte neexistujú alebo heslo nie je nastavené, vráti hodnotu None. V takom prípade operácia .start() vytvorí otvorenú sieť bez hesla (security=network.WLAN.SEC_OPEN), inak sieť zabezpečenú heslom (security=network.WLAN.SEC_WPA_WPA2).

Task

Overte funkčnosť prístupového bodu.

V servisnom termináli zapnite prístupový bod:

> ap start
Access point thsensor-ab123cd started.
> ap stat
Active   : yes
SSID     : thsensor-ab123cd
Security : open
IP       : 192.168.4.1
Clients  : 0

Vyskúšajte oba prípady: bez hesla a po nastavení hesla podpríkazom settings set ap.password <heslo>. Pripojte sa k sieti telefónom alebo počítačom a overte, že zariadenie odpovedá na adrese 192.168.4.1 (napr. príkazom ping) a že v položke Clients pribudlo pripojené zariadenie. Nakoniec prístupový bod vypnite podpríkazom ap stop.

Stav Configuration

Zariadenie sa v tomto stave prepne do režimu prístupového bodu s webovým rozhraním, kde môže používateľ nastaviť správanie zariadenia, ako napr. údaje na pripojenie do WiFi siete a k MQTT brokeru. Počas zotrvávania v tomto stave bude diagnostická LED dióda svietiť farbou Color.ORANGE.

Zariadenie do stavu Configuration prejde zo stavu Init (pozri obrázok 1), ak nastavenia chýbajú alebo sú poškodené, alebo po krátkom stlačení ovládacieho tlačidla.

Po ukončení konfigurácie zariadenie overí novú konfiguráciu prechodom do stavu Commissioning. V tomto kroku vytvoríme tento stav, zabezpečíme prechod doň a zapneme v ňom prístupový bod.

---
# spolocna konfiguracia stavovych diagramov THSensor; vklada sa ako prvy
# riadok bloku ```mermaid, za nim nasleduje "flowchart LR" a samotny diagram
config:
  flowchart:
    curve: basis
    nodeSpacing: 40
    rankSpacing: 60
  themeVariables:
    fontSize: 20px
  themeCSS: ".edgeLabel, .edgeLabel p, .labelBkg { background-color: var(--bs-body-bg) !important; }"
---

flowchart LR
    start@{ shape: f-circ } --> init("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Init</div>")
    init --> measurement("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Measurement</div>")
    init -- "<code>SVC_PIN</code><br>grounded" --> terminal("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Service<br>Terminal</div>")
    init -- "long button<br>press" --> reset("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Factory<br>Reset</div>")
    init -- "no/faulty<br><code>settings.json</code> /<br>short button press" --> configuration("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Configuration</div>")
    measurement --> connect("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Connect<br>Network</div>")
    connect -- "connected" --> publish("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Publish<br>Data</div>")
    connect -- "connection<br>failed" --> disconnect("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Disconnect<br>Network</div>")
    publish --> disconnect
    disconnect --> sleep("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Sleep</div>")
    sleep --> sleepEnd@{ shape: fr-circ }
    terminal --> terminalEnd@{ shape: fr-circ }
    reset --> resetEnd@{ shape: fr-circ }
    configuration --> configurationEnd@{ shape: fr-circ }

    %% spolocne styly stavovych diagramov THSensor (farby podla
    %% images/thsensor-state.diagram.complete.png); vklada sa na koniec diagramu
    %% a stavy sa k nim priradia cez "class <stavy> <styl>"
    classDef state fill:#cfe2ff,stroke:#9aa3ad,stroke-width:3px,color:#343a40,font-weight:bold
    classDef service fill:#cbc3a3,stroke:#9aa3ad,stroke-width:3px,color:#343a40,font-weight:bold
    classDef reset fill:#ffd6f5,stroke:#9aa3ad,stroke-width:3px,color:#343a40,font-weight:bold
    classDef config fill:#fddba7,stroke:#9aa3ad,stroke-width:3px,color:#343a40,font-weight:bold
    classDef error fill:#ffd7d9,stroke:#9aa3ad,stroke-width:3px,color:#343a40,font-weight:bold
    classDef ota fill:#c3f5c8,stroke:#9aa3ad,stroke-width:3px,color:#343a40,font-weight:bold
    classDef initial fill:#000,stroke:#fff,stroke-width:2px
    classDef final fill:#fff,stroke:#000,stroke-width:2px

    class init,measurement,connect,publish,disconnect,sleep state
    class terminal service
    class reset reset
    class configuration config
    class start initial
    class sleepEnd,terminalEnd,resetEnd,configurationEnd final

Obr. 1: Stavový diagram zariadenia so stavom Configuration

Task

Vytvorte stav Configuration, ktorý zapne prístupový bod.

Stav vytvorte v module configuration.py balíka states. V metóde .enter() rozsvieťte diagnostickú LED diódu a zapnite prístupový bod. Metóda .exec() zatiaľ len vráti hodnotu None, takže sa stavový stroj hneď ukončí.

Task

Zabezpečte prechod zo stavu Init do stavu Configuration.

Zariadenie prejde do stavu Configuration za týchto podmienok:

  • ak súbor s nastaveniami neexistuje,
  • ak je súbor s nastaveniami poškodený,
  • po krátkom stlačení ovládacieho tlačidla.

Za krátke stlačenie považujeme stlačenie aspoň na čas SHORT_PRESS_DURATION, ale kratšie ako LONG_PRESS_DURATION.

V prvých dvoch prípadoch vytvorte predvolené nastavenia, aby ich mal stav Configuration k dispozícii, a prejdite do stavu Configuration:

try:
    SettingsCommand(self.context).load()
except Exception as ex:
    print(f'Settings not loaded: {ex}')
    self.context.settings = Settings()
    return Configuration(self.context)

Task

Upravte stav FactoryReset tak, aby len zmazal nastavenia a reštartoval zariadenie.

Predvolené nastavenia už nevytvára. Po reštarte stav Init zistí, že súbor s nastaveniami chýba, a prejde do stavu Configuration.

Task

Overte správnosť svojej implementácie.

Pri overovaní nesmie byť uzemnený pin SVC_PIN, inak zariadenie prejde do servisného terminálu. Vyskúšajte tieto situácie:

  1. krátke stlačenie tlačidla pri štarte zariadenia,
  2. chýbajúci súbor s nastaveniami,
  3. poškodený súbor s nastaveniami,
  4. dlhé stlačenie tlačidla, po ktorom zariadenie zmaže nastavenia a prejde do stavu Configuration,
  5. štart bez stlačenia tlačidla s platnými nastaveniami, ktorý prejde do stavu Measurement.

Vo všetkých situáciách s prechodom do stavu Configuration sa pripojte telefónom alebo počítačom do siete zariadenia a overte, že odpovedá na adrese 192.168.4.1, napr. príkazom ping.

Webový server

Keď sa používateľ pripojí do siete zariadenia, potrebuje na ňom niečo, čo si otvorí vo webovom prehliadači. Zariadenie preto bude poskytovať webový server. Vytvoríme ho pomocou rámca Microdot, ktorý je navrhnutý pre zariadenia s malým množstvom pamäte. Knižnicu nájdete v priečinku lib/ kostry projektu, takže ju netreba inštalovať.

Webová aplikácia sa skladá z trás (z angl. route). Trasa je adresa spolu s metódou HTTP, napr. GET /. Každú trasu spracuje obsluha trasy (z angl. route handler), teda asynchrónna funkcia označená dekorátorom, ktorá dostane požiadavku a vráti odpoveď. Môže to vyzerať nasledovne:

@app.get('/ping')           # dekorátor: určuje trasu
async def ping(request):      # obsluha trasy: funkcia s požiadavkou v parametri
    return 'pong'             # odpoveď pre klienta

Dekorátor @app.get() zaregistruje funkciu pod ním ako obsluhu trasy. Keď klient pošle požiadavku na adresu /ping, rámec zavolá funkciu ping() a jej návratovú hodnotu pošle ako odpoveď. Názov funkcie sa pritom nemusí zhodovať s adresou trasy. O tom, ktorá funkcia sa zavolá, rozhoduje len dekorátor. Viac sa dozviete v dokumentácii webového rámca.

Task

Vytvorte v module routes.py balíka web webovú aplikáciu s úvodnou stránkou.

Vytvorte objekt app triedy Microdot a obsluhu trasy /, ktorá vráti krátky text. Môže to vyzerať nasledovne:

from microdot import Microdot

app = Microdot()


@app.get('/')
async def index(request):
    return 'Hello from the smart device!'

Task

Rozšírte stav Configuration o spustenie webového servera.

V metóde .exec() spustite webový server a až potom vráťte hodnotu None. Doplňte aj metódu .exit(), ktorá prístupový bod vypne operáciou .stop() príkazu ap. Tieto metódy môžu vyzerať nasledovne:

from web.routes import app

# ...

def exec(self) -> BaseState:
    app.run(port=80)
    return None

def exit(self):
    AP(self.context).stop()

Task

Overte funkčnosť úvodnej stránky.

Prejdite do stavu Configuration, pripojte sa do siete zariadenia a otvorte vo webovom prehliadači adresu http://192.168.4.1/. Ak ste postupovali správne, zobrazí sa text z úvodnej stránky.

Task

Vytvorte obsluhu trasy /static/<path:path>, ktorá bude posielať súbory z priečinka web/static/.

Na odoslanie súboru použite funkciu send_file() z knižnice Microdot. Parametrom max_age povoľte prehliadaču súbor si uložiť, aby ho zariadenie neposielalo pri každej stránke znova. Súbor so štýlom pico.min.css je v tomto priečinku súčasťou kostry projektu.

Ak súbor neexistuje, funkcia send_file() vyvolá výnimku OSError. Zachyťte ju a vráťte kód 404.

Obsluha trasy môže vyzerať nasledovne:

from microdot import Microdot, send_file

# ...

@app.get('/static/<path:path>')
async def static(request, path):
    # directory traversal is not allowed
    if '..' in path:
        return 'Not found', 404

    try:
        return send_file(f'web/static/{path}', max_age=86400)
    except OSError:
        return 'Not found', 404

Task

Overte funkčnosť trasy pre statické súbory.

Prejdite do stavu Configuration a overte, že:

  1. adresa http://192.168.4.1/static/pico.min.css vráti súbor so štýlom,
  2. adresa neexistujúceho súboru vráti kód 404,
  3. pokus o prístup k súboru s nastaveniami vráti kód 404.

Prehliadač sekvenciu .. z adresy sám odstráni, preto tretiu možnosť overte z príkazového riadku, napr. pomocou nástroja curl:

$ curl --path-as-is -i http://192.168.4.1/static/../../data/settings.json

Šablónovací systém

Rámec Microdot podporuje šablóny (z angl. template), vďaka ktorým oddelíme kód HTML od kódu v jazyku MicroPython. Pohľad na údaje je tak oddelený od logiky aplikácie, čo je základná myšlienka návrhového vzoru Model-View-Controller (skrátene MVC).

Na prácu so šablónami použijeme knižnicu utemplate, ktorá je súčasťou kostry projektu. Šablóna sa pri prvom použití preloží do modulu jazyka MicroPython, ktorý sa uloží vedľa nej, napr. index_html.py. Zobrazenie stránky je preto rýchle aj na mikrokontroléri.

Vzhľad stránok zabezpečí CSS rámec Pico CSS, ktorý nepotrebuje takmer žiadne triedy v kóde HTML.

Task

Vytvorte priečinok web/templates/ a inicializujte v module routes.py šablónovací systém.

Priečinok web/templates/ bude obsahovať všetky šablóny webového rozhrania. Rámcu Microdot je potrebné oznámiť, kde sa tieto šablóny nachádzajú. Treba tiež nastaviť predvolený typ odpovede na HTML, aby prehliadač vrátené stránky správne zobrazil. To zabezpečíme týmto fragmentom kódu:

from microdot import Microdot, Response, send_file
from microdot.utemplate import Template

app = Microdot()
Response.default_content_type = 'text/html'
Template.initialize(template_dir='web/templates')

Task

Vytvorte šablónu index.html a zobrazte ju na úvodnej stránke.

Šablóna index.html bude zatiaľ vyzerať takto:

{% args title %}
<h1>{{ title }}</h1>

Šablóna začína príkazom args, ktorý určí, aké hodnoty dostane. Hodnotu do textu vložíte v dvojitých zložených zátvorkách, napr. {{ title }}.

V obsluhe trasy / šablónu zobrazte a odovzdajte jej hodnotu:

@app.get('/')
async def index(request):
    return Template('index.html').render(title='Smart device')

Overte, že sa v stave Configuration na adrese http://192.168.4.1/ zobrazí nadpis.

Task

Vytvorte šablóny header.html a footer.html so spoločnou časťou stránok a použite ich v šablóne index.html.

Knižnica utemplate nepodporuje dedenie šablón, ale príkazom include vie vložiť inú šablónu. Spoločný začiatok a koniec stránok tak napíšete len raz. Hlavička bude dostávať názov stránky ako parameter:

{% args title %}
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <link rel="stylesheet" href="/static/pico.min.css">
    <title>{{ title }}</title>
</head>
<body>
<main class="container">
    <nav>
        <ul>
            <li><strong>{{ title }}</strong></li>
        </ul>
        <ul>
            <li><a href="/">Home</a></li>
        </ul>
    </nav>

Pätička uzavrie otvorené značky:

</main>
</body>
</html>

V šablóne index.html ich použite takto:

{% args title %}
{% include "header.html" title %}
    <h1>{{ title }}</h1>
{% include "footer.html" %}

Ak ste postupovali správne, stránka bude mať štýl Pico CSS a navigáciu.

Task

Sprístupnite obsluhám trás kontext zariadenia.

Objekt app vzniká pri importe modulu, a preto o kontexte nevie. Stránky ho však budú potrebovať, napr. na prácu s nastaveniami alebo zariadeniami. Preto ho v stave Configuration uložte do objektu app ešte pred spustením servera:

def exec(self) -> BaseState:
    app.context = self.context
    app.run(port=80)
    return None

Obsluha trasy sa ku kontextu dostane pomocou request.app.context.

Task

Zobrazte na úvodnej stránke v tabuľke identifikátor zariadenia, názov siete a verziu softvéru.

Názov siete získate operáciou .ssid() príkazu ap a verziu z konštanty VERSION. Do šablóny odovzdajte zoznam dvojíc (názov, hodnota) a zobrazte ich v cykle:

{% args title, items %}
{% include "header.html" title %}
    <table>
        {% for name, value in items %}
        <tr>
            <th>{{ name }}</th>
            <td>{{ value }}</td>
        </tr>
        {% endfor %}
    </table>
{% include "footer.html" %}

Ak ste postupovali správne, úvodná stránka zobrazí tabuľku so skutočnými hodnotami zo zariadenia.

Prvé nastavenie

Pri prvom spustení je sieť zariadenia otvorená a pripojiť sa k nej môže ktokoľvek. Prvým krokom preto bude vytvorenie účtu pre administrátora. Kým ho používateľ nevytvorí, zariadenie mu nedovolí urobiť nič iné.

Na tejto jednoduchej stránke si vyskúšame prácu s formulárom: zobrazenie, spracovanie odoslaných údajov aj zobrazenie chyby.

Task

Rozšírte používateľské nastavenia o účet administrátora.

Vytvorte triedu Admin s členskými premennými username a password s predvolenou hodnotou None. V triede Settings bude dostupná v členskej premennej admin. Vytvorte validátory, ktoré overia, že meno nie je prázdne a heslo má aspoň 8 znakov.

Task

Vytvorte šablónu setup.html s formulárom na vytvorenie účtu administrátora.

Šablóna dostane parametre title a error a chybu zobrazí len vtedy, ak nastala. Formulár odošle údaje metódou POST na adresu /setup:

{% args title, error %}
{% include "header.html" title %}
    <h1>{{ title }}</h1>
    {% if error %}
    <p><mark>{{ error }}</mark></p>
    {% endif %}
    <form method="post" action="/setup">
        <label>
            Username
            <input type="text" name="username" required>
        </label>
        <label>
            Password
            <input type="password" name="password" minlength="8" required>
        </label>
        <button type="submit">Save</button>
    </form>
{% include "footer.html" %}

Task

Vytvorte obsluhy trasy /setup, ktoré zobrazia formulár a spracujú odoslané údaje.

Metódy HTTP na rovnakej adrese môžu mať samostatné obsluhy. Zatiaľ sme používali dekorátor @app.get() pre metódu GET, údaje z formulára sa odosielajú metódou POST, pre ktorú slúži dekorátor @app.post().

Hodnoty z formulára získate zo slovníka request.form. Z nich vytvorte objekt triedy Admin a uložte ho do nastavení. Ak validátor vyvolá výnimku ValueError, zobrazte formulár znova s textom chyby. Po úspešnom vytvorení presmerujte používateľa na úvodnú stránku funkciou redirect(). Ak administrátor už existuje, stránka používateľa rovno presmeruje:

def setup_page(error=None):
    return Template('setup.html').render(title='First setup', error=error)


@app.get('/setup')
async def setup_form(request):
    if request.app.context.settings.admin.username is not None:
        return redirect('/')

    return setup_page()


@app.post('/setup')
async def setup_save(request):
    settings = request.app.context.settings
    if settings.admin.username is not None:
        return redirect('/')

    try:
        settings.admin = Admin(
            username=request.form.get('username', ''),
            password=request.form.get('password', '')
        )
    except ValueError as ex:
        return setup_page(error=str(ex))

    return redirect('/')

Task

Zabezpečte, aby sa používateľ bez vytvoreného administrátora dostal len na stránku /setup.

Webový rámec Microdot obsahuje špeciálny dekorátor s názvom @app.before_request. Ak je funkcia dekorovaná týmto dekorátorom, vykoná sa pred každou obsluhou trasy. Výsledok môže dopadnúť dvoma spôsobmi:

  • ak funkcia vráti None, vykonávanie pokračuje a zavolá sa obsluha trasy,
  • ak funkcia vráti HTTP odpoveď, tak je rovno preposlaná ako odpoveď klientovi.

Kontrolu tak napíšete na jednom mieste. Výnimkou musia byť adresa /setup a statické súbory, inak by sa stránka zacyklila alebo by nemala štýl:

@app.before_request
async def require_setup(request):
    is_open = request.path == '/setup' or request.path.startswith('/static/')
    if not is_open and request.app.context.settings.admin.username is None:
        return redirect('/setup')

Task

Overte správnosť svojej implementácie.

Overte tieto situácie:

  1. adresa http://192.168.4.1/ používateľa presmeruje na stránku /setup,
  2. príliš krátke heslo zobrazí chybu a administrátor sa nevytvorí,
  3. správne údaje presmerujú používateľa na úvodnú stránku,
  4. opätovné otvorenie stránky /setup presmeruje na úvodnú stránku.

Formulár s nastaveniami

V tomto kroku vytvoríte stránku /settings, na ktorej používateľ nastaví zariadenie. Navrhnite formulár tak, aby umožnil zmeniť všetky používateľské nastavenia: WiFi sieť, MQTT broker, jednotky, NTP server a heslo prístupového bodu.

Pri návrhu myslite na tieto situácie:

  • Hodnoty z formulára sú vždy reťazce. Pred uložením ich preto musíte previesť na správny typ, napr. port na číslo. Zaškrtávacie pole sa odošle len vtedy, keď je zaškrtnuté.
  • Formulár sa pri zobrazení vyplní aktuálnymi hodnotami, heslá však do stránky nikdy nevypisujte. Prázdne pole pre heslo bude znamenať, že sa heslo nemení.
  • O kontrolu hodnôt sa postarajú validátory triedy Settings. Ak niektorý z nich vyvolá výnimku ValueError, zobrazte formulár znova s textom chyby a so zadanými hodnotami, aby ich používateľ nemusel písať odznova.
  • Neplatné údaje nesmú zmeniť aktuálne nastavenia, preto hodnoty najprv overte a až potom ich uložte.
  • Nové nastavenia uložte len do členskej premennej kontextu settings, nie do súboru. Takto sa prenesú do stavu Commissioning, ktorý ich do súboru uloží až po ich overení.

Rozloženie formulára je na vás. Inšpirujte sa ukážkami formulárov štýlu Pico CSS.

Stav Commissioning

V stave Commissioning zariadenie overí nastavenia zadané používateľom. Ak overenie prebehne úspešne, nastavenia uloží. V oboch prípadoch sa potom vráti do stavu Configuration, kde používateľ uvidí výsledok overenia. Ak sa overenie nepodarilo, používateľ môže nastavenia opraviť. Ak sa podarilo, reštartuje zariadenie tlačidlom na webovej stránke, takže pri ďalšom spustení začne bežnú činnosť stavom Measurement.

Stavový diagram zariadenia s novým stavom Commissioning sa nachádza na obrázku 2.

---
# spolocna konfiguracia stavovych diagramov THSensor; vklada sa ako prvy
# riadok bloku ```mermaid, za nim nasleduje "flowchart LR" a samotny diagram
config:
  flowchart:
    curve: basis
    nodeSpacing: 40
    rankSpacing: 60
  themeVariables:
    fontSize: 20px
  themeCSS: ".edgeLabel, .edgeLabel p, .labelBkg { background-color: var(--bs-body-bg) !important; }"
---

flowchart LR
    start@{ shape: f-circ } --> init("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Init</div>")
    init --> measurement("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Measurement</div>")
    init -- "<code>SVC_PIN</code><br>grounded" --> terminal("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Service<br>Terminal</div>")
    init -- "long button<br>press" --> reset("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Factory<br>Reset</div>")
    init -- "no/faulty<br><code>settings.json</code> /<br>short button press" --> configuration("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Configuration</div>")
    measurement --> connect("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Connect<br>Network</div>")
    connect -- "connected" --> publish("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Publish<br>Data</div>")
    connect -- "connection<br>failed" --> disconnect("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Disconnect<br>Network</div>")
    publish --> disconnect
    disconnect --> sleep("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Sleep</div>")
    sleep --> sleepEnd@{ shape: fr-circ }
    terminal --> terminalEnd@{ shape: fr-circ }
    reset --> resetEnd@{ shape: fr-circ }
    configuration --> commissioning("<div style='width:164px;height:48px;display:flex;align-items:center;justify-content:center;line-height:1.2'>Commissioning</div>")
    commissioning -- "verification<br>result" --> configuration
    configuration -- "restart" --> configurationEnd@{ shape: fr-circ }

    %% spolocne styly stavovych diagramov THSensor (farby podla
    %% images/thsensor-state.diagram.complete.png); vklada sa na koniec diagramu
    %% a stavy sa k nim priradia cez "class <stavy> <styl>"
    classDef state fill:#cfe2ff,stroke:#9aa3ad,stroke-width:3px,color:#343a40,font-weight:bold
    classDef service fill:#cbc3a3,stroke:#9aa3ad,stroke-width:3px,color:#343a40,font-weight:bold
    classDef reset fill:#ffd6f5,stroke:#9aa3ad,stroke-width:3px,color:#343a40,font-weight:bold
    classDef config fill:#fddba7,stroke:#9aa3ad,stroke-width:3px,color:#343a40,font-weight:bold
    classDef error fill:#ffd7d9,stroke:#9aa3ad,stroke-width:3px,color:#343a40,font-weight:bold
    classDef ota fill:#c3f5c8,stroke:#9aa3ad,stroke-width:3px,color:#343a40,font-weight:bold
    classDef initial fill:#000,stroke:#fff,stroke-width:2px
    classDef final fill:#fff,stroke:#000,stroke-width:2px

    class init,measurement,connect,publish,disconnect,sleep state
    class terminal service
    class reset reset
    class configuration,commissioning config
    class start initial
    class sleepEnd,terminalEnd,resetEnd,configurationEnd final

Obr. 2: Stavový diagram zariadenia so stavom Commissioning

Task

Zabezpečte ukončenie webového servera po odoslaní nastavení.

Keď obsluha trasy /settings nastavenia overí a uloží do kontextu, musí ukončiť server. Ak by ho ukončila hneď, klient by nedostal obsah odpovede. Preto server ukončíme s krátkym oneskorením v samostatnej asynchrónnej úlohe:

import asyncio

# ...

async def shutdown_later(server):
    await asyncio.sleep(1)
    server.shutdown()

Obsluha trasy túto úlohu spustí a oznámi to používateľovi:

asyncio.create_task(shutdown_later(request.app))
return Template('commissioning.html').render(title='Commissioning')

Stránku commissioning.html vytvorte sami. Po ukončení servera sa vykonávanie vráti z volania app.run(). Stav Configuration už nevracia hodnotu None, ale prejde do stavu Commissioning.

Task

Vytvorte stav Commissioning, ktorý overí a uloží nastavenia.

Stav vytvorte v module commissioning.py balíka states. Overované nastavenia nájde v členskej premennej kontextu settings, kam ich uložila stránka /settings. Počas overovania bude diagnostická LED dióda svietiť farbou Color.CYAN. Stav postupne:

  1. pripojí zariadenie do WiFi siete operáciou .connect() príkazu net,
  2. synchronizuje čas operáciou .sync() príkazu rtc; ak sa to nepodarí, vypíše príčinu a pokračuje,
  3. pripojí zariadenie k brokeru operáciou .connect() príkazu mqtt a zverejní stav zariadenia online,
  4. odpojí zariadenie od brokera aj od siete.

Ak overenie zlyhá s výnimkou NetworkError alebo MQTTError, stav sa musí tiež odpojiť. Text chyby uloží do novej členskej premennej kontextu commissioning_error a prejde do stavu Configuration.

Ak overenie prebehne úspešne, stav uloží nastavenia do súboru operáciou .save() príkazu settings. Až teraz sa pôvodný súbor s nastaveniami prepíše. Úspech overenia stav poznačí v novej členskej premennej kontextu commissioned nastavením na hodnotu True a prejde do stavu Configuration.

Task

Zobrazte používateľovi výsledok overenia nastavení.

Ak overenie zlyhalo, stránka /settings zobrazí text z členskej premennej kontextu commissioning_error a nastaví ju na hodnotu None, aby sa pri ďalšom zobrazení už neukazovala.

Ak overenie prebehlo úspešne, teda členská premenná kontextu commissioned má hodnotu True, stránka to používateľovi oznámi a ponúkne mu tlačidlo na reštart zariadenia.

Task

Vytvorte trasu /restart, ktorá reštartuje zariadenie.

Tlačidlo na reštart odošle formulár metódou POST na adresu /restart. Obsluha trasy zobrazí stránku s oznámením, že sa zariadenie reštartuje, a samotný reštart vykoná s krátkym oneskorením, podobne ako pri ukončení servera, aby klient stihol dostať odpoveď. Zariadenie reštartujte natvrdo funkciou machine.reset().

Task

Overte správnosť svojej implementácie.

Vyskúšajte tieto situácie:

  1. správne nastavenia – zariadenie sa pripojí, v téme <base_topic>/status sa objaví stav online, nastavenia sa uložia, na stránke /settings sa zobrazí výsledok overenia a po stlačení tlačidla na reštart začne zariadenie bežnú činnosť,
  2. nesprávne heslo k WiFi sieti – zariadenie sa vráti do stavu Configuration a na stránke /settings sa zobrazí chyba,
  3. nesprávne prihlasovacie údaje k brokeru – zariadenie sa zachová rovnako ako v predchádzajúcom prípade.

Prihlásenie administrátora

Administrátora sme už vytvorili, ale stránka s nastaveniami je stále prístupná každému, kto je pripojený do siete zariadenia. V tomto kroku ju preto chránime menom a heslom.

Použijeme overenie HTTP Basic, ktoré poskytuje rozšírenie microdot.auth. Prehliadač sa používateľa sám opýta na meno a heslo a pri ďalších požiadavkách ich posiela automaticky, takže netreba vytvárať vlastnú prihlasovaciu stránku.

Task

Vytvorte funkciu, ktorá overí meno a heslo administrátora.

V module routes.py vytvorte objekt triedy BasicAuth. Funkcia označená dekorátorom @auth.authenticate dostane zadané meno a heslo. Ak sú správne, vráti meno používateľa, inak nevráti nič:

from microdot.auth import BasicAuth

auth = BasicAuth()


@auth.authenticate
async def verify(request, username, password):
    admin = request.app.context.settings.admin
    if username == admin.username and password == admin.password:
        return username

Task

Chráňte obsluhy trás /settings a /restart heslom administrátora.

Dekorátor @auth pridajte pod dekorátor trasy všetkým týmto obsluhám, teda pri trase /settings pre metódy GET aj POST:

@app.get('/settings')
@auth
async def settings_form(request):
    # ...

Task

Overte správnosť svojej implementácie.

Overte tieto situácie:

  1. stránka /settings si vypýta meno a heslo,
  2. nesprávne heslo prístup nepovolí a server vráti kód 401,
  3. so správnymi údajmi sa stránka zobrazí,
  4. úvodná stránka / zostáva verejná.

Pred odchodom z cvičenia…

Ďalšie úlohy

  1. Presuňte spoločný kód tried CYW43439 a CYW43439AP do spoločného predka.

  2. Doplňte do stavu Configuration časový limit nečinnosti. Ak sa počas neho nič nestane, zariadenie konfiguráciu ukončí.

  3. Vytvorte stránku Info so súhrnnými vlastnosťami zariadenia.

  4. Vytvorte stránku Measurements s neodoslanými meraniami a tlačidlom na nové meranie.

  5. Vytvorte stránku Devices so zoznamom zariadení a možnosťou ich otestovania.

  6. Doplňte do formulára výber WiFi siete zo zoznamu dostupných sietí.

  7. Zabezpečte, aby sa po pripojení k prístupovému bodu webové rozhranie otvorilo samo (captive portal).

Ďalšie zdroje

  1. Microdot – dokumentácia webového rámca

  2. Pico CSS – dokumentácia štýlu

  3. Práca s WiFi modulom – vytvorenie prístupového bodu

Zoznam zmien

2026-11-04

  • Krok „Stav Commissioning“: stav po úspešnom overení nastavenia uloží, nastaví členskú premennú kontextu commissioned na hodnotu True a vráti sa do stavu Configuration, namiesto toho, aby zariadenie reštartoval.
  • Krok „Stav Commissioning“: stránka /settings zobrazí výsledok overenia a po úspešnom overení ponúkne tlačidlo na reštart.
  • Krok „Stav Commissioning“: nová trasa /restart, ktorá zariadenie reštartuje.
  • Krok „Prihlásenie administrátora“: heslom sa chráni aj trasa /restart.