Lab #3: Servisný terminál

diagnostický režim zariadenia, sériová linka, interpreter príkazov

Motivácia

Naše zariadenie vybavíme špeciálnym stavom, v ktorom budeme môcť vykonávať diagnostiku zariadenia. Jeho podstatou bude, že sa do neho budeme môcť dostať hneď po zapnutí zariadenia a tento stav bude úplne mimo hlavného cyklu nášho zariadenia.

  • diagnostický režim
  • servisný terminál
  • komunikácia pomocou sériovej linky
  • príkazový riadok (REPL)

Ciele

  1. Vytvoriť diagnostický režim zariadenia ako samostatný stav stavového stroja.

  2. Komunikovať so zariadením pomocou sériovej linky.

  3. Aplikovať návrhové vzory Príkaz (z angl. Command) a Register (z angl. Registry) v jazyku MicroPython.

  4. Osvojiť si návrhový vzor iterátor.

  5. Porozumieť významu návrhového vzoru Null Object.

Postup

Stav ServiceTerminal

Diagnostický režim zariadenia bude reprezentovaný samostatným stavom s názvom ServiceTerminal.

Do servisného terminálu sa zariadenie dostane zo stavu Init a po jeho ukončení sa vykonávanie stavového stroja skončí. Stavový diagram zariadenia s novým stavom sa nachádza na obrázku 1.

---
# 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(Init)
    init --> measurement(Measurement)
    init -- "<code>SVC_PIN</code><br>grounded" --> terminal("Service<br>Terminal")
    measurement --> sleep(Sleep)
    sleep --> sleepEnd@{ shape: fr-circ }
    terminal --> terminalEnd@{ 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,sleep state
    class terminal service
    class start initial
    class sleepEnd,terminalEnd final

Obr. 1: Stavový diagram zariadenia so stavom ServiceTerminal

Aby technik na prvý pohľad videl, že sa zariadenie nachádza v servisnom režime, vyhradíme pre tento stav vlastnú farbu diagnostickej LED diódy.

Task

V balíku states vytvorte v module service_terminal.py triedu ServiceTerminal, ktorá bude reprezentovať servisný terminál.

O triede bude platiť nasledovné:

  • trieda bude potomkom triedy BaseState.

  • .enter() – Rozsvieti diagnostickú LED diódu farbou vyhradenou pre servisný režim, ktorá bude (5, 5, 5). Táto farba sa nesmie zhodovať so žiadnou farbou, ktorú používajú ostatné stavy.

  • .exec() – Vypíše uvítaciu správu servisného terminálu, napr. "# Welcome to Service Terminal", a ako nasledujúci stav vráti hodnotu None. Tým sa vykonávanie stavového stroja zatiaľ ukončí.

  • .exit() – V tejto metóde diagnostickú LED diódu zhasnite.

Task

Overte vytvorenú implementáciu.

Stav zatiaľ nie je prepojený so žiadnym iným stavom, takže sa doň stavový stroj nemá ako dostať. Pri overovaní preto využijeme to, že kontext dostáva počiatočný stav ako parameter. V súbore main.py dočasne zmeňte počiatočný stav zo stavu Init na stav ServiceTerminal:

if __name__ == "__main__":
    context = Context(register_devices(), ServiceTerminal)
    context.run()

Ak ste postupovali správne, po spustení sa diagnostická LED dióda rozsvieti farbou servisného režimu, v termináli sa zobrazí uvítacia správa a vykonávanie stavového stroja sa ukončí.

Po overení vráťte v súbore main.py počiatočný stav späť na stav Init.

Prechod do servisného terminálu zo stavu Init

Do servisného režimu zariadenia sa používateľ nemôže dostať len tak. A nemal by sa do neho dostať ani náhodou. Ak sa chce aj napriek tomu používateľ do tohto režimu dostať, obyčajne to znamená, že potrebuje zariadenie rozobrať a skratovať dva piny pomocou jumpera. A presne toto riešenie vytvoríme aj my.

Do servisného terminálu sa zariadenie dostane priamo zo stavu Init, ak bude servisný pin (v konštantách označený ako SVC_PIN) uzemnený (prepojený priamo s pinom GND).

Task

Zaregistrujte v správcovi zariadení servisné tlačidlo.

Z pohľadu programu sa skratovaný jumper správa rovnako ako stlačené tlačidlo, preto na jeho reprezentáciu použijeme triedu Button. Servisné tlačidlo bude pripojené na pin SVC_PIN a pri registrácii do správcu zariadení dostane alias ALIAS_SERVICE_BTN.

Task

Zabezpečte, aby v stave Init došlo k prechodu do stavu ServiceTerminal, ak je pri štarte stlačené servisné tlačidlo.

Task

Overte svoju implementáciu.

Ak ste postupovali správne, po zapnutí zariadenia dôjde k odmeraniu teploty a vykonávanie sa ukončí. Ak však uzemníte SVC_PIN, diagnostická LED dióda zmení farbu a v termináli si prečítate uvítaciu správu.

Sériová linka

Na komunikáciu so servisným terminálom použijeme sériovú linku (z angl. serial line). Práca s ňou je veľmi jednoduchá a zvláda ju každý mikrokontrolér. Aj preto majú servisný (konzolový) port napr. aj sieťové zariadenia, priemyselné riadiace systémy či 3D tlačiarne.

So servisným terminálom nebudeme komunikovať cez USB kábel, cez ktorý zariadenie programujeme. Vyhradíme mu samostatné hardvérové sériové rozhranie UART. K počítaču ho pripojíme pomocou USB-TTL kábla, ktorý sa v počítači tvári ako ďalší sériový port.

Task

Prepojte svoj počítač s chytrým zariadením pomocou USB-TTL kábla.

USB-TTL kábel má na jednej strane USB konektor a na druhej strane štyri vodiče zakončené dutinkami (pozri obrázok 2). Význam jednotlivých vodičov podľa farieb je nasledovný:

farba význam
červená napájanie +5V – nezapájať
čierna zem (GND)
zelená TX – kábel údaje odosiela
biela RX – kábel údaje prijíma
Obr. 2: Význam vodičov USB-TTL kábla (ilustrácia: AI)

Doska RPi Pico 2 WH sa s káblom prepojí len tromi vodičmi: GND, TX a RX. Dátové vodiče sa pritom zapájajú do kríža – vysielač (TX) jednej strany je pripojený na prijímač (RX) druhej strany a naopak. Zelený vodič kábla preto pripojte na pin UART_RX_PIN dosky, biely vodič na pin UART_TX_PIN a čierny vodič na pin GND. Zapojenie ilustruje obrázok 3.

Obr. 3: Krížové zapojenie sériovej linky medzi doskou a USB-TTL prevodníkom (ilustrácia: AI)

Z USB-TTL kábla sa môžete priamo napojiť na mikrokontrolér alebo na dutinky Grove prepojovacieho kábla. Rozloženie jeho pinov je zobrazené na obrázku 4.

Obr. 4: Význam vodičov prepojovacieho kábla Grove s dutinkami (ilustrácia: AI)

Zariadenie sériovej linky

Prístup k sériovej linke je v jazyku MicroPython zabezpečený pomocou triedy UART. Mikrokontroléry RP2040 a RP2350 obsahujú dve rozhrania UART označené číslami 0 a 1. To, ktoré z nich použijeme a na ktorých pinoch bude prebiehať komunikácia, je uvedené v module constants.py v konštantách s prefixom UART_.

V tomto kroku prístup k sériovej linke zabalíme do samostatnej triedy.

Task

Vytvorte triedu SerialPort, ktorá implementuje SerialMixin a je potomkom triedy BaseDevice.

Rovnako ako ostatné zariadenia dostane trieda svoje nastavenia ako parametre konštruktora: číslo rozhrania UART, piny pre vysielanie a príjem a rýchlosť prenosu. V konštruktore z nich vytvorí inštanciu triedy UART:

def __init__(self, uart_id: int, tx_pin: int, rx_pin: int, baudrate: int, alias=None):
    BaseDevice.__init__(self, alias)
    self.pins = {'tx': tx_pin, 'rx': rx_pin}
    self.uart = UART(uart_id, tx=tx_pin, rx=rx_pin, baudrate=baudrate)

Pri vytváraní inštancie potom použijete konštanty z modulu constants.py, napr. SerialPort(UART_ID, UART_TX_PIN, UART_RX_PIN, UART_BAUDRATE).

Mixin opisujúci správanie sériovej linky obsahuje metódy .read(), .write() a .any(). Ich volanie trieda SerialPort prenechá inštancii triedy UART, ktorá ich už implementuje.

Súčasťou mixinu SerialMixin je aj implementovaná metóda .input(). Tá je postavená na metódach .read(), .write() a .any(). Načítava vstup zo sériovej linky a každý prijatý znak pošle späť, aby používateľ videl, čo píše. Túto metódu preto implementovať nemusíte.

Okrem toho bude mať trieda vlastnú metódu .deinit(), ktorá sériovú linku uvoľní.

O triede SerialPort bude platiť nasledovné:

  • jej implementácia sa bude nachádzať v module serial_port.py,
  • názov zariadenia bude "SerialPort",
  • opis zariadenia bude "Serial port (UART)",
  • bude implementovať mixin SerialMixin a bude potomkom triedy BaseDevice,
  • konštruktor bude mať parametre uart_id, tx_pin, rx_pin, baudrate a voliteľný alias,
  • piny uloží do členskej premennej pins ako {'tx': tx_pin, 'rx': rx_pin},
  • metódy .read(), .write() a .any() prenechajú volanie inštancii triedy UART,
  • metóda .deinit() uvoľní sériovú linku zavolaním metódy .deinit() inštancie triedy UART.

Task

Overte funkčnosť vytvorenej triedy SerialPort.

Pripojte sa k sériovej linke pomocou vhodného klienta, napr.:

  • rozšírenie Serial Port Monitor pre vývojové prostredie PyCharm
  • ak používate OS Windows, môžete použiť klienta PuTTY
  • ak používate OS Linux, môžete použiť klienta picocom (je tiež súčasťou Docker obrazu bletvaska/iotbox)

Triedu si potom môžete vyskúšať v REPL jazyka MicroPython cez USB. Vytvorte inštanciu sériovej linky, pošlite do klienta správu a následne si od používateľa vyžiadajte vstup:

>>> from constants import UART_ID, UART_TX_PIN, UART_RX_PIN, UART_BAUDRATE
>>> from hw.serial_port import SerialPort
>>> port = SerialPort(UART_ID, UART_TX_PIN, UART_RX_PIN, UART_BAUDRATE)
>>> port.write('Hello from RPi Pico!\r\n')
22
>>> port.input('> ')  # v klientovi napíšte hello a stlačte Enter
'hello'
>>> port.capabilities()
['SerialMixin']
>>> port.deinit()

Ak ste postupovali správne, v klientovi sa zobrazí správa "Hello from RPi Pico!" a výzva "> ". Text, ktorý v klientovi napíšete, sa v ňom zároveň zobrazí a po stlačení klávesu Enter ho metóda .input() vráti v REPL ako reťazec.

Servisný terminál cez sériovú linku

Sériovú linku máme pripravenú ako samostatnú perifériu. V tomto kroku ju zaregistrujeme v správcovi zariadení a tým z nej spravíme súčasť chytrého zariadenia. Následne ju použijeme v servisnom termináli na komunikáciu s používateľom.

Task

Zaregistrujte sériovú linku v správcovi zariadení.

Sériová linka je periféria ako každá iná, preto ju zaregistrujeme spolu s ostatnými vo funkcii register_devices(). Pri jej vytváraní použite konštanty UART_ID, UART_TX_PIN, UART_RX_PIN a UART_BAUDRATE a pri registrácii jej priraďte alias ALIAS_SERIAL.

Task

V metóde .enter() stavu ServiceTerminal si od správcu zariadení vyžiadajte sériovú linku a uložte ju do členskej premennej self.serial.

Uvítaciu správu v metóde .exec() potom vypíšte do sériovej linky:

print('# Welcome to Service Terminal', file=self.serial)

Ak ste postupovali správne, po reštartovaní zariadenia v servisnom režime sa vo vašom klientovi sériovej linky zobrazí uvítacia správa.

Interaktívna slučka (REPL)

REPL je akronym pre Read–Eval–Print Loop. Konzola v slučke prečíta (read) príkaz, vyhodnotí ho (eval), vypíše výsledok (print) a celý postup opakuje (loop). Práve takúto slučku spolu s niekoľkými jednoduchými príkazmi vytvoríte v tomto kroku.

Task

V metóde .exec() vytvorte nekonečnú slučku REPL, ktorá bude zatiaľ fungovať ako jednoduché echo: načítaný vstup len pošle späť.

V každom prechode slučkou:

  1. vypíšte výzvu "> " a načítajte vstup od používateľa pomocou metódy .input() nad objektom sériovej linky,
  2. zo vstupu odstráňte zbytočné biele znaky,
  3. ak je vstup prázdny, nerobte nič a pokračujte ďalším prechodom,
  4. inak vstup vypíšte späť do sériovej linky pomocou funkcie print(..., file=self.serial).

Task

Overte svoju implementáciu.

Pripojte sa k zariadeniu pomocou klientskej aplikácie a reštartujte ho v servisnom režime. Po uvítacej správe sa zobrazí výzva "> ". Ak napíšete ľubovoľný text a stlačíte Enter, zariadenie vám ho vráti späť.

Slučku je zatiaľ možné ukončiť len reštartovaním zariadenia, prípadne stlačením CTRL+C v konzole REPL cez USB. Stlačenie CTRL+C v klientovi sériovej linky ju neukončí – do slučky príde ako obyčajný znak.

Task

Vytvorte príkaz version, ktorý zobrazí verziu firmvéru zariadenia, a overte ho.

Je to jeden zo základných príkazov servisného terminálu, pretože technik na zariadení zvyčajne najprv zisťuje, akú verziu firmvéru obsahuje. Príkaz vypíše hodnoty konštánt z modulu version.

Rozšírte slučku v metóde .exec() o vetvu pre príkaz version. Ak zariadenie zadaný príkaz nepozná, vypíše správu "Unknown command".

Výsledná naivná implementácia slučky môže vyzerať nasledovne:

from version import VERSION, BUILD_DATE, BOARD

# ...

    def exec(self):
        while True:
            line = self.serial.input('> ').strip()

            if line == '':
                continue
            elif line == 'version':
                print(f'Version   : {VERSION}', file=self.serial)
                print(f'Build date: {BUILD_DATE}', file=self.serial)
                print(f'Board     : {BOARD}', file=self.serial)
            else:
                print('Unknown command', file=self.serial)

Ak ste postupovali správne, v konzole servisného terminálu sa vypíše aktuálna verzia firmvéru:

>
> hello world
Unknown command
>
> version
Version   : 2026.9
Build date: 2026-10-12T21:54:57Z
Board     : Raspberry Pi Pico 2 W

Task

Vytvorte príkaz na reštartovanie zariadenia a svoju implementáciu overte.

Reštartovaním sa zároveň ukončí servisný režim. Je to zatiaľ jediný spôsob, ako servisný terminál opustiť.

Zariadenie môžeme reštartovať dvoma spôsobmi:

  • mäkký reštart (z angl. soft reset) – reštartuje len interpreter jazyka MicroPython (vymaže premenné a znova spustí boot.py a main.py), zatiaľ čo mikrokontrolér beží ďalej a spojenie cez USB zostane zachované

  • tvrdý reštart (z angl. hard reset) – reštartuje celý mikrokontrolér rovnako ako jeho vypnutie a zapnutie, vrátane všetkých periférií, takže sa zariadenie od počítača na chvíľu odpojí

Mäkký reštart je možné vyvolať stlačením klávesovej skratky CTRL+D v konzole REPL alebo zavolaním funkcie machine.soft_reset(). Tvrdý reštart je možné vyvolať funkciou machine.reset().

Vytvorte príkaz reset s jedným parametrom, ktorý určí spôsob reštartu:

  • reset soft – mäkký reštart,
  • reset hard – tvrdý reštart.

Obe varianty pridajte do slučky ako ďalšie vetvy elif. Neznámy príkaz sa naďalej ohlási správou "Unknown command".

Ak ste postupovali správne, zariadenie sa po zadaní ktorejkoľvek z oboch variant príkazu reštartuje.

Task

Vytvorte príkaz uptime a svoju implementáciu overte.

Pomocou príkazu uptime zistíte, ako dlho je zariadenie spustené od posledného reštartu.

Na tento účel môžeme s výhodou použiť funkciu time.ticks_ms(), ktorá vráti počet milisekúnd od zapnutia mikrokontroléra. Pri tvrdom reštarte sa toto počítadlo vynuluje, pri mäkkom reštarte však nie.

Nás ale zaujíma, ako dlho zariadenie beží od svojho posledného reštartu – mäkkého aj tvrdého. Preto si hodnotu funkcie ticks_ms() zapamätáme pri spustení zariadenia a uptime vypočítame ako rozdiel aktuálnej a zapamätanej hodnoty. Do triedy Context pridajte členskú premennú started_at, ktorú pri inicializovaní kontextu zariadenia nastavíte na hodnotu funkcie ticks_ms().

Ak ste postupovali správne, po zadaní príkazu sa zobrazí počet uplynutých milisekúnd od posledného mäkkého alebo tvrdého reštartu zariadenia.

Parser príkazov

Servisný terminál môže obsahovať množstvo príkazov, ktoré môžu mať vlastné podpríkazy alebo ďalšie prepínače. Ak by sme pokračovali v implementácii interpretera príkazov pomocou if-elif-else tak, ako doteraz, náš kód by sa veľmi rýchlo stal neprehľadným a ťažko udržiavateľným. Každý nový príkaz by znamenal ďalšiu vetvu v jednej stále rastúcej metóde a kvôli každému z nich by sme museli meniť triedu ServiceTerminal. To je v rozpore s princípom otvorenosti a uzavretosti.

Tento problém vyriešime pomocou dvoch návrhových vzorov:

  • Príkaz (z angl. Command) – každý príkaz bude reprezentovaný samostatnou triedou v balíku commands, ktorá pozná svoj názov a opis a vie sa vykonať,
  • Register (z angl. Registry) – parser si príkazy zaregistruje a pre načítaný riadok vyhľadá príkaz podľa jeho názvu.

Pridanie nového príkazu bude potom znamenať len vytvorenie novej triedy a jej zaregistrovanie – bez zásahu do servisného terminálu. A keďže parser pozná všetky zaregistrované príkazy, môžeme ich kedykoľvek vypísať alebo k nim zobraziť pomoc. Na tento účel neskôr vytvoríme príkazy commands a help.

Task

Rozšírte triedu Context o členskú premennú parser.

Súčasťou kostry chytrého zariadenia je aj trieda Parser v module parser.py. Práve inštancia tejto triedy bude slúžiť ako parser servisného terminálu.

Trieda Parser poskytuje tieto metódy:

  • .register(command) – zaregistruje príkaz pod názvom z jeho premennej name,
  • .parse(line) – rozoberie riadok a vráti dvojicu (príkaz, parametre).

Parser implementuje návrhový vzor iterátor, vďaka čomu dokáže pomocou cyklu for prechádzať zaregistrované príkazy. Okrem toho pomocou operátora in dokáže overiť, či je príkaz s daným názvom zaregistrovaný, a pomocou operátora indexovania [] ho podľa názvu dokáže získať. Tieto vlastnosti využijete neskôr pri tvorbe príkazov commands a help.

>>> parser.parse('reset soft')
(<Reset object at …>, ['soft'])
>>> 'reset' in parser
True
>>> parser['reset']
<Reset object at …>

Inštanciu parsera vytvorte pri inicializácii kontextu.

Task

Vytvorte príkaz version, ktorý vypíše verziu firmvéru zariadenia.

Každý príkaz servisného terminálu je definovaný v balíku commands a je potomkom triedy BaseCommand z modulu base.py. V triede príkazu:

  • v triednej premennej name uvediete názov, ktorým sa príkaz v termináli spúšťa,
  • v triednej premennej description uvediete jeho krátky opis,
  • v metóde .__call__() implementujete samotné vykonanie príkazu; parameter params obsahuje zoznam parametrov, ktoré používateľ zadal za názvom príkazu, a parameter out prúd, do ktorého príkaz zapisuje svoj výstup (predvolene sys.stdout).

O príkaze version bude platiť:

  • jeho implementácia sa bude nachádzať v module version.py,
  • názov triedy, ktorá ho bude implementovať, bude Version,
  • názov príkazu bude "version",
  • opis príkazu bude "Show firmware version."

Ku kontextu zariadenia má príkaz prístup cez členskú premennú self.context, ktorú nastaví konštruktor triedy BaseCommand.

Implementácia príkazu version môže vyzerať nasledovne:

import sys

from version import VERSION, BUILD_DATE, BOARD

from .base import BaseCommand


class Version(BaseCommand):
    name = 'version'
    description = 'Show firmware version.'

    def __call__(self, params: list, out=sys.stdout) -> None:
        print(f'Version   : {VERSION}', file=out)
        print(f'Build date: {BUILD_DATE}', file=out)
        print(f'Board     : {BOARD}', file=out)

Telo metódy .__call__() je takmer rovnaké ako vetva elif z naivnej implementácie. Príkaz však nepíše priamo do sériovej linky, ale do prúdu out, ktorý mu odovzdá ten, kto ho volá. Príkaz tak nemusí vedieť, kam jeho výstup smeruje.

Task

Vytvorte príkaz devices, ktorý zobrazí zoznam všetkých zariadení zaregistrovaných v správcovi zariadení.

O tomto príkaze bude platiť:

  • jeho implementácia sa bude nachádzať v module devices.py,
  • názov triedy, ktorá ho bude implementovať, bude Devices,
  • názov príkazu bude "devices",
  • opis príkazu bude "List registered devices."

Správca zariadení je dostupný v kontexte (self.context.devices) a implementuje návrhový vzor iterátor, takže zaregistrované zariadenia môžete prechádzať cyklom for. Informácie o každom zariadení vráti jeho metóda .info().

Príkaz zaregistrujete a overíte spolu s ostatnými príkazmi v nasledujúcich úlohách. Jeho výstup by mal byť podobný tomuto:

> devices
[0] DHT22 (temp, humidity)
  description : Temperature and humidity sensor
  pins        : data=3
  capabilities: TemperatureMixin, HumidityMixin
[1] WS2812B (diag_led)
  description : RGB LED controller
  pins        : data=18
  capabilities: OnOffMixin, ColorlightMixin
[2] Button (service_btn)
  description : Push button
  pins        : data=15
  capabilities: ButtonMixin
[3] SerialPort (serial)
  description : Serial port (UART)
  pins        : tx=16, rx=17
  capabilities: SerialMixin

Task

Vytvorte implementáciu príkazov uptime a reset ako triedy Uptime a Reset v balíku commands.

Postupujte podobne ako v prípade príkazu version: každý príkaz vytvorte v samostatnom module ako potomka triedy BaseCommand, nastavte mu názov a opis a jeho vykonanie presuňte z vetvy elif do metódy .__call__(). Pri implementácii myslite na to, že:

  • príkaz uptime potrebuje kontext zariadenia – čas spustenia nájde v členskej premennej self.context.started_at,
  • príkaz reset dostane spôsob reštartu (soft alebo hard) ako prvú položku zoznamu params.

O príkaze uptime bude platiť:

  • jeho implementácia sa bude nachádzať v module uptime.py,
  • názov triedy, ktorá ho bude implementovať, bude Uptime,
  • názov príkazu bude "uptime",
  • opis príkazu bude "Show time elapsed since last restart."

O príkaze reset bude platiť:

  • jeho implementácia sa bude nachádzať v module reset.py,
  • názov triedy, ktorá ho bude implementovať, bude Reset,
  • názov príkazu bude "reset",
  • opis príkazu bude "Reset the device (soft or hard)."

Task

Zaregistrujte vytvorené príkazy version, devices, uptime a reset do parsera v metóde .enter() servisného terminálu.

Parser pozná len tie príkazy, ktoré sú v ňom zaregistrované. Preto je potrebné všetky príkazy pred ich použitím najprv zaregistrovať.

Príkazy zaregistrujte v metóde .enter() stavu servisného terminálu, teda ešte predtým, ako sa v metóde .exec() spustí jeho slučka. Registráciu príkazov ilustruje nasledovný fragment kódu:

from commands.version import Version
from commands.devices import Devices
from commands.uptime import Uptime
from commands.reset import Reset

# ...

parser = self.context.parser
parser.register(Version(self.context))
parser.register(Devices(self.context))
parser.register(Uptime(self.context))
parser.register(Reset(self.context))

Task

Aktualizujte REPL slučku vášho servisného terminálu.

Upravte slučku v metóde .exec() tak, aby namiesto vetiev elif vyhľadala príkaz pomocou metódy .parse() parsera a nájdený príkaz zavolala s jeho parametrami. Ako výstupný prúd mu odovzdajte sériovú linku self.serial, aby výstup príkazu smeroval do servisného terminálu.

Rozpoznávanie príkazov teraz prevzal parser, takže slučka o konkrétnych príkazoch nič nevie. Zodpovedá len za načítanie riadka a spustenie nájdeného príkazu. Vykonanie príkazu je zodpovednosťou jeho triedy, čo je podstatou princípu jedinej zodpovednosti (SRP). Nový príkaz pridáme vytvorením novej triedy a jej registráciou, bez zmeny slučky. Slučka je tak otvorená pre rozširovanie, ale uzavretá voči zmenám, ako to vyžaduje princíp otvorenosti a uzavretosti (OCP).

Task

Overte správnosť svojej implementácie.

Ak ste postupovali správne, po prechode zariadenia do servisného terminálu budú príkazy version, uptime a reset fungovať rovnako ako pred úpravou a k dispozícii bude aj nový príkaz devices.

Vyskúšajte aj stlačiť len Enter alebo zadať príkaz, ktorý neexistuje. Zariadenie sa zachová správne – na prázdny riadok nezareaguje a na neznámy príkaz odpovie správou "Unknown command: <názov>" – hoci ste v slučke žiadnu podmienku pre tieto prípady nenapísali. Prečo to tak je, si vysvetlíme v nasledujúcom kroku.

Príkaz measure

Chytré zariadenie meria teplotu a vlhkosť v stave Measurement. Technik však niekedy potrebuje zistiť aktuálne hodnoty hneď, napríklad keď overuje, či je senzor správne zapojený, alebo porovnáva namerané hodnoty s referenčným teplomerom. V tomto kroku preto vytvoríme príkaz measure, ktorý meranie vykoná na požiadanie priamo zo servisného terminálu.

Rovnakú operáciu potrebujeme na zariadení vykonávať na dvoch miestach: v stave Measurement a v príkaze measure. Neskôr môžu dokonca pribudnúť ďalšie, napr. webové rozhranie zariadenia. Pri tvorbe príkazu bude preto potrebné, aby bola logika merania napísaná len raz a aby ju mohol použiť ktokoľvek, kto ju potrebuje.

Task

Vytvorte a overte príkaz measure, ktorý zmeria a vypíše aktuálnu teplotu a vlhkosť.

O tomto príkaze bude platiť:

  • jeho implementácia sa bude nachádzať v module measure.py,
  • názov triedy, ktorá ho bude implementovať, bude Measure,
  • názov príkazu bude "measure",
  • opis príkazu bude "Measure temperature and humidity."

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

Senzory teploty a vlhkosti získate od správcu zariadení pomocou aliasov ALIAS_TEMP a ALIAS_HUMIDITY, rovnako ako v stave Measurement.

Ak ste postupovali správne, po zadaní príkazu sa zobrazia aktuálne namerané hodnoty:

> measure
Temperature : 23.4 °C
Humidity    : 41 %

Task

Upravte stav Measurement tak, aby na meranie použil príkaz measure.

Cieľom je, aby bol kód merania v zariadení napísaný len na jednom mieste. Stav aj príkaz dostávajú v konštruktore kontext, takže príkaz môžete v stave vytvoriť a zavolať rovnako, ako ho volá servisný terminál.

Príkaz je síce možné zo stavu zavolať, ale namerané hodnoty len vypíše, a stav s nimi preto nemôže ďalej pracovať. Jediný spôsob, ako sa k nim stav dostane, je, že meranie vykoná sám, teda skopíruje kód merania z príkazu. Kód merania by tak bol v zariadení dvakrát, čím by sme porušili princíp DRY (z angl. Don’t Repeat Yourself). Príčinou je to, že príkaz má dve zodpovednosti – vykonáva meranie a zároveň vypisuje jeho výsledok – čo je v rozpore s princípom jedinej zodpovednosti (SRP).

V triede príkazu teda rozdelíme zodpovednosti:

  • vytvoríme samostatnú metódu .measure(), ktorá vykoná meranie a namerané hodnoty vráti (napr. ako slovník), ale nič nevypíše, a
  • metóda .__call__() zavolá metódu .measure() a vrátené hodnoty vypíše.

Metódu, ktorá vykoná samotnú činnosť príkazu, vráti výsledok a nič nevypíše, budeme nazývať operácia príkazu.

Príkaz potom môže vyzerať nasledovne:

import sys

from constants import ALIAS_TEMP, ALIAS_HUMIDITY
from hw.mixins.sensors.temperature import TemperatureUnit
from .base import BaseCommand


class Measure(BaseCommand):
    name = 'measure'
    description = 'Measure temperature and humidity.'

    def measure(self) -> dict:
        unit = TemperatureUnit.METRIC
        return {
            'temperature': self.context.devices.get(ALIAS_TEMP).temperature(unit),
            'unit': unit,
            'humidity': self.context.devices.get(ALIAS_HUMIDITY).humidity(),
        }

    def __call__(self, params: list, out=sys.stdout) -> None:
        values = self.measure()
        print(f"Temperature : {values['temperature']} °C", file=out)
        print(f"Humidity    : {values['humidity']} %", file=out)

Stav Measurement potom použije len metódu .measure() a s nameranými hodnotami môže ďalej pracovať:

from commands.measure import Measure

# ...


class Measurement(BaseState):
    # ...

    def exec(self) -> BaseState:
        values = Measure(self.context).measure()
        print(f"Temperature: {values['temperature']}°C | Humidity: {values['humidity']}%")

        return Sleep(self.context)

Kód merania je tak v zariadení len raz – v metóde .measure(). Ak ho bude potrebné zmeniť, zmena sa prejaví v stave aj v príkaze naraz.

Ak ste postupovali správne, príkaz measure bude v servisnom termináli fungovať rovnako ako pred úpravou a zariadenie v stave Measurement vypíše namerané hodnoty rovnako ako doteraz.

Task

Rozdeľte na operáciu a výpis aj ďalšie príkazy, pri ktorých to má zmysel.

Operácia má zmysel tam, kde by výsledok príkazu mohol potrebovať aj niekto iný ako servisný terminál, napríklad stav zariadenia. Operáciu, ktorá vracia informácie, pomenujte podľa toho, čo vracia (napr. .get() alebo .list()), a operáciu, ktorá vykonáva činnosť, pomenujte slovesom (napr. .measure() alebo .connect()).

Špeciálne príkazy

Okrem príkazov určených na správu zariadenia bude servisný terminál obsahovať aj príkazy, ktoré používateľovi pomôžu zorientovať sa v samotnom termináli. Na tento účel vytvoríme príkazy commands a help.

V balíku commands sa však už od začiatku nachádzajú aj dve pripravené triedy – EmptyCommand a UnknownCommand. Práve vďaka nim servisný terminál správne zareaguje na prázdny riadok aj na neznámy príkaz. V tomto kroku sa na ne pozrieme bližšie.

Task

Vytvorte a overte príkaz commands, ktorý zobrazí zoznam všetkých zaregistrovaných príkazov spolu s ich krátkym opisom.

O tomto príkaze bude platiť:

  • jeho implementácia sa bude nachádzať v module commands.py,
  • názov triedy, ktorá ho bude implementovať, bude Commands,
  • názov príkazu bude "commands",
  • opis príkazu bude "Show list of available commands."

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

Zaregistrované príkazy je možné získať z inštancie parsera, ktorý implementuje návrhový vzor iterátor. Pri výpise využite triedne premenné name a description jednotlivých príkazov.

Ak ste postupovali správne, po zadaní príkazu sa zobrazí zoznam podobný tomuto:

> commands
List of available commands:
  version      Show firmware version.
  devices      List registered devices.
  uptime       Show time elapsed since last restart.
  reset        Reset the device (soft or hard).
  commands     Show list of available commands.

Task

Vytvorte a overte príkaz help, ktorý zobrazí opis zadaného príkazu.

O tomto príkaze bude platiť:

  • jeho implementácia sa bude nachádzať v module help.py,
  • názov triedy, ktorá ho bude implementovať, bude Help,
  • názov príkazu bude "help",
  • opis príkazu bude "Show usage of a given command."

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

Názov príkazu, ktorého použitie chce používateľ zobraziť, dostane ako svoj jediný parameter. Ak príkaz s takým názvom nie je zaregistrovaný, vypíše správu "Command not found."

Ak príkaz so zadaným názvom zaregistrovaný je, vypíše jeho názov a opis v tvare <názov>: <opis>. Ak používateľ zadá nesprávny počet parametrov, vypíše správu "Wrong number of parameters.".

Ak ste postupovali správne, výpis opisu príkazov bude vyzerať podobne ako tento:

> help
Wrong number of parameters.
> help blabla
Command not found.
> help version
version: Show firmware version.
> help reset
reset: Reset the device (soft or hard).

Task

Preskúmajte triedy EmptyCommand a UnknownCommand a metódu .parse() triedy Parser.

Servisný terminál zareaguje na prázdny riadok a neznámy príkaz správne, hoci jeho slučka o týchto prípadoch nič nevie. Odpoveď na otázku, prečo je to tak, sa skrýva v kóde, ktorý ste dostali spolu s kostrou zariadenia. V tejto úlohe ju nájdete sami.

Aby ste riešeniu lepšie porozumeli, pokúste sa nájsť odpovede na tieto otázky:

  • Čo vráti metóda .parse() pre prázdny riadok a čo pre príkaz, ktorý nie je zaregistrovaný?
  • Čo sa stane, keď slučka servisného terminálu takýto príkaz zavolá?
  • Prečo sa tieto príkazy nezobrazia vo výpise príkazu commands?
  • Prečo nie sú tieto príkazy v parseri zaregistrované?

Svoje odpovede si môžete overiť priamo v REPL jazyka MicroPython. Stačí vytvoriť inštanciu parsera a neregistrovať do nej žiadne príkazy:

>>> from parser import Parser
>>> parser = Parser()
>>> parser.parse('')
(<EmptyCommand object at …>, [])
>>> parser.parse('blabla')
(<UnknownCommand object at …>, [])

Ak ste na otázky našli odpovede, zistili ste, že parser nikdy nevráti hodnotu None. Aj pre prázdny riadok či neznámy príkaz vráti plnohodnotný príkaz, ktorý sa dá zavolať rovnako ako ktorýkoľvek iný. Obe triedy sú teda ukážkou návrhového vzoru Null Object. Namiesto hodnoty None, ktorú by musel každý, kto parser používa, kontrolovať, vráti parser objekt s rovnakým rozhraním ako ostatné príkazy. Ten sa v danej situácii zachová správne: prázdny príkaz neurobí nič a neznámy príkaz vypíše chybovú správu.

Ani jeden z týchto príkazov však nie je v parseri zaregistrovaný. Parser ich pri každom volaní metódy .parse() vytvorí ako novú inštanciu, takže ich používateľ nemôže zavolať podľa názvu a nezobrazia sa ani vo výpise príkazu commands. Príkaz UnknownCommand navyše pri vytvorení dostane názov neznámeho príkazu, aby ho mohol vypísať v chybovej správe.

Keďže parser vždy vráti príkaz, ktorý sa dá zavolať, slučka servisného terminálu nepotrebuje žiadnu podmienku:

cmd, params = parser.parse(line)
cmd(params, self.serial)

Pred odchodom z cvičenia…

Ďalšie úlohy

  1. Upravte implementáciu príkazu uptime tak, aby výsledok zobrazoval podobným spôsobom, ako je to v prípade príkazu uptime v linuxových systémoch.

  2. Rozšírte príkaz reset o kontrolu servisného pinu. Ak je SVC_PIN v čase zadania príkazu stále uzemnený, zariadenie by sa po reštarte znova ocitlo v servisnom termináli. V takom prípade reštart nevykonajte a upozornite používateľa vhodnou správou, že pred reštartom je potrebné servisný pin odpojiť, napr.:

    > reset soft
    SVC_PIN is still grounded, disconnect it first.
    >

    Ak SVC_PIN uzemnený nie je, príkaz zariadenie reštartuje rovnako ako doteraz. Stav pinu zistite rovnakým spôsobom ako v stave Init.

  3. Rozšírte príkaz help tak, aby okrem názvu a opisu príkazu zobrazil aj spôsob jeho použitia.

    Spôsob použitia príkazu je uložený v triednej premennej usage rodičovskej triedy. Ak príkaz túto premennú nenastaví, má hodnotu None. Inak obsahuje n-ticu dvojíc (použitie, opis). Pre príkazy reset a help ju nastavte na tieto hodnoty:

    # Reset
    usage = (
        ('reset soft', 'Soft reset.'),
        ('reset hard', 'Hard reset.'),
    )
    
    # Help
    usage = (
        ('help <cmd>', 'Show usage of the command <cmd>.'),
    )

    Ak má príkaz premennú usage nastavenú, príkaz help pod riadok s jeho názvom a opisom vypíše všetky možnosti jeho použitia. Príklad takéhoto výpisu môže vyzerať nasledovne:

    > help reset
    reset: Reset the device (soft or hard).
    Usage:
      reset soft                     Soft reset.
      reset hard                     Hard reset.

    Ak používateľ zadá príkaz help s nesprávnym počtom parametrov, vypíše príkaz help aj spôsob svojho použitia:

    > help
    Wrong number of parameters.
    help: Show usage of a given command.
    Usage:
      help <cmd>                     Show usage of the command <cmd>.

    Ak sa niektorý príkaz spustí s nesprávnym parametrom, vypíše chybovú správu s textom "Error: Wrong usage." a odkáže používateľa na príkaz help:

    > reset blabla
    Error: Wrong usage.
    Type 'help reset' for usage.

Ďalšie zdroje

  1. Quick reference for the RP2 – (krátka) dokumentácia jazyka MicroPython pre dosku Raspberry Pi Pico

  2. class UART – duplex serial communication bus – UART implements the standard UART/USART duplex serial communications protocol.

  3. Reštartovanie zariadenia – mäkký a tvrdý reštart mikrokontroléra v jazyku MicroPython

  4. Iterátor – návrhový vzor iterátor

Literatúra

[1] RASPBERRY PI LTD. RP2350 Datasheet: A microcontroller by Raspberry Pi. In [online]. 2024. [cit. 2026-09-01]. Dostupné na internete: <https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf>.