Przejdź do treści

Etap 3 — Kiosk UI + OTA pipeline

Etap 3 dorzucił dwa duże klocki: (a) Flutter kiosk dla fizycznego paczkomatu (Windows desktop primary, Linux secondary) i (b) end-to-end OTA pipeline który z poziomu panelu pozwala wgrać nową wersję, przypisać ją do maszyny i obserwować live progress instalacji.

Chunk Commit Co wleciało
3.1 3da4759 · a8bb7e8 backend release management — MachineRelease, MachineReleaseAssignment, multipart upload, SHA-256, /releases/:id/file stream
3.2 95d5a60 panel: <UploadRelease> + <ReleasesTable> + <AssignRelease> + <ReleaseStatusCard> (4 s polling)
3.3 + 3.4 1889e72 Flutter kiosk skeleton + OTA updater (kontekst lokalny w aplikacji)
3.5 9fbc148cebc9b3 pickup flow + ErgoFlow dark theme + RFID/PIN tabs + custom PIN pad + 12 polish dropów (v0.35v0.47)
infra ada368f · d46a564 · 4019227 Windows kiosk bootstrap (PS scripts) + headless OTA agent + auto-logon Scheduled Task

Najtrudniejsza decyzja etapu 3

Czy aplikacja Flutter sama sobie zarządza OTA, czy outsourcujemy to do zewnętrznego procesu? Pierwotny chunk 3.4 zrobił updater w aplikacji (Dart + Process.run do unzipa). Polish drop 9 (10cd430) usunął cały ten kod i przesunął OTA do PowerShell agenta jako LocalSystem Scheduled Task. Powód: aplikacja nie może zatrzymać samej siebie i zamienić swojego pliku exe — Windows trzyma binary z lock-iem dopóki proces żyje. Agent jest osobnym procesem, który ma prawo pokazać UI splash przez state-file, zatrzymać UI Scheduled Task, podmienić junction, wystartować nową wersję.

Topologia kiosku — Windows desktop

graph LR
    classDef ui fill:#0AD6E8,stroke:#10F3FF,color:#06101A,stroke-width:2px
    classDef agent fill:#F5C16C,stroke:#F5C16C,color:#06101A
    classDef hw fill:#5EE6A0,stroke:#5EE6A0,color:#06101A
    classDef state fill:#1A2C42,stroke:#0AD6E8,color:#fff

    UI["UI · SmartBoxKioskUI<br/>Flutter Windows desktop<br/>Scheduled Task pod kiosk-userem"]:::ui
    Agent["Agent · SmartBoxKioskAgent<br/>PowerShell 5.1<br/>Scheduled Task pod LocalSystem"]:::agent

    StateFile[("update-state.json<br/>C:/smartbox-kiosk/")]:::state
    Junction[("C:/smartbox-kiosk/current<br/>(junction → versions/0.83.0)")]:::state
    Versions[("C:/smartbox-kiosk/versions/<br/>0.78.0/ · 0.79.0/ · 0.83.0/")]:::state

    Modbus[Modbus TCP<br/>latch + sensor]:::hw
    RFIDReader[RFID HID-keyboard<br/>card reader]:::hw

    Backend[(Backend API<br/>X-Machine-Api-Key)]
    Releases[(release file storage<br/>/data/releases)]

    UI --> StateFile
    Agent --> StateFile
    Agent --> Junction
    Agent --> Versions
    Junction --> UI

    UI --> Modbus
    UI --> RFIDReader
    UI --> Backend
    Agent --> Backend
    Agent --> Releases

Dwie Scheduled Task-i w Windowsie:

Task User Trigger Co robi
SmartBoxKioskUI kiosk-user (auto-logon) logon uruchamia Flutter app fullscreen, always-on-top, borderless
SmartBoxKioskAgent LocalSystem system start + co 30 s poll GET /machines/me/release-assignment, jak PENDING to ściąga + instaluje

UI nie wie o agencie ani na odwrót — komunikują się przez plik update-state.json na dysku. UI poll-uje co 1 s (UpdateStateWatcher), agent zapisuje stan przy każdej fazie OTA.

OTA — pełny flow

sequenceDiagram
    autonumber
    participant Admin
    participant Panel
    participant Backend
    participant Storage as /data/releases
    participant Agent as PS agent (LocalSystem)
    participant UI as Flutter UI
    participant State as update-state.json

    Admin->>Panel: System → Wersje → Wgraj
    Panel->>Backend: POST /releases (multipart, max 500 MB)
    Backend->>Storage: zapisz, hash SHA-256
    Backend-->>Panel: { id, sha256, fileSize }

    Admin->>Panel: machine detail → Aktualizuj → wybierz wersję
    Panel->>Backend: POST /machines/:id/releases
    Backend->>Backend: insert MachineReleaseAssignment (PENDING)

    Note over Agent: co 30 s

    Agent->>Backend: GET /machines/me/release-assignment
    Backend-->>Agent: { id, version, sha256, fileUrl } (PENDING)
    Agent->>State: phase: "downloading"
    UI->>State: read every 1s
    UI->>UI: render <UpdateSplash /> downloading

    Agent->>Backend: GET /releases/:id/file (stream)
    Backend-->>Agent: binary + X-Release-SHA256 header
    loop 256 KB buffer
      Agent->>Backend: POST /machines/me/release-assignment/:id/status<br/>DOWNLOADING + progressPct (5 s throttle)
    end

    Agent->>Agent: weryfikuj SHA-256
    alt mismatch
      Agent->>State: phase: "failed"
      Agent->>Backend: status FAILED
    else OK
      Agent->>State: phase: "installing"
      Agent->>Agent: rozpakuj do versions/<ver>/
      Agent->>Agent: atomic junction swap → current
      Agent->>State: phase: "restarting"
      Agent->>Agent: Stop + Start SmartBoxKioskUI Scheduled Task
    end

    UI->>State: phase = "installed", lingering 3 s
    UI->>UI: fade do login
    Agent->>Backend: status INSTALLED + installedVersion
    Backend->>Backend: Machine.currentVersion = installedVersion

End-to-end na świeżej ~12 MB release: ~3 s download + ~10 s install + ~3 s "installed" linger.

InactivityGuard pause podczas OTA

UpdateStateWatcher widzi phase != "idle" i podaje sygnał do InactivityScope.pauseWhile(). Inactivity overlay jest wstrzymane przez cały czas trwania OTA — user nie zostaje wylogowany w środku update-u.

Kiosk UI — flow użytkownika

Etap 3.5 ustabilizował screens:

graph TB
    classDef screen fill:#0AD6E8,stroke:#10F3FF,color:#06101A
    classDef offl fill:#5EE6A0,stroke:#5EE6A0,color:#06101A

    Login["LoginScreen<br/>RFID tab (default) + PIN tab"]:::screen
    Menu["UserMenuScreen<br/>Odbierz · Nadaj · Profil"]:::screen
    Pickup["PickupScreen<br/>lista DEPOSITED dla użytkownika"]:::screen
    Open["LockerOpenScreen<br/>open / take / close dance"]:::screen
    NoP["NoParcelsScreen<br/>sad-face fallback"]:::offl

    DepRecip["DepositRecipientScreen<br/>typeahead /deposit/recipients"]:::screen
    DepSize["DepositSizeScreen<br/>size + function chips"]:::screen
    DepLook["DepositLookupScreen<br/>last-6 tracking lookup"]:::screen
    DepOK["DepositSuccessScreen<br/>QR + tracking + recipient"]:::screen

    Profile["ProfileScreen<br/>edit phone + locale"]:::screen

    Login --> Menu
    Menu -->|Odbierz · mam pending| Pickup
    Menu -->|Odbierz · brak pending| NoP
    Menu -->|Nadaj · employee| DepRecip
    Menu -->|Nadaj · courier z trackingiem| DepLook
    Menu -->|Profil| Profile

    Pickup --> Open
    Open --> Login

    DepRecip --> DepSize
    DepLook --> DepSize
    DepSize --> Open
    Open --> DepOK
    DepOK --> Login

UserMenuScreen gat-uje "Nadaj":

  • EMPLOYEE widzi tylko Odbierz.
  • ADMIN + COURIER widzą Odbierz + Nadaj.

Login ma 2-flag picker (PL / EN) i live machine name + location z GET /machines/me co 30 s.

InactivityScope — etap 3.5 polish 7

Pojedynczy guard na app root zamiast per-screen:

  • InactivityController — single source of truth. bump() resetuje idle clock; pauseWhile<T>(Future<T>) jest reference-counted (kilka in-flight network calls się nakłada, timer wraca do biegu dopiero gdy wszystko skończy).
  • InactivityNavigatorObserver — wpięty w MaterialApp.navigatorObservers. bump() na każdym didPush/didPop/didReplace.
  • InactivityGuard — overlay z gradientowym countdown-em (cyan→leaf), "Czy jesteś tam jeszcze?" copy. Idle przez inactivityTimeoutSec (default 5 s) → fade in; countdown inactivityCountdownSec (default 10 s) → bounce do login. Touch dismiss-uje. Kiedy busyCount > 0 overlay jest ukryty — user czekający na /pickup/by-user nie wyloguje się w środku fetch-u.

Oba timeouts są per-machine, edytowane z panelu (Edit-machine dialog), surfaced przez GET /machines/me.

Brand kiosku — ErgoFlow dark

Etap 3.5 (7e0311b) dał kioskowi własny look:

  • ink-950 backdrop + 24 ErgoFlow bubbles wolniej niż w panelu (~60–80 s cykle)
  • custom PIN pad (3×4 + duże dot indicators) zamiast systemowej klawiatury
  • big touch targets (etap-5 polish dorzucił jeszcze większe — etap-3 stał na ~80 px)
  • 2-flag picker w header (PL + EN)
  • live header chip: machine name + location + sync status

Pełen detal → Kiosk (Flutter).

Deploy kiosku — bootstrap-all.ps1

graph LR
    A[Goła Win 11<br/>Pro x64] -->|bootstrap-all.ps1| B[Hostname + auto-logon]
    B --> C[Pobranie smartbox-kiosk-bundle.zip]
    C --> D[Rozpakowanie do<br/>C:/smartbox-kiosk/versions/0.83.0]
    D --> E[Junction current → 0.83.0]
    E --> F[Scheduled Task<br/>SmartBoxKioskUI]
    F --> G[Scheduled Task<br/>SmartBoxKioskAgent]
    G --> H[Reboot → kiosk live]

Czas od goła Windows-a do działającego kiosku z UI fullscreen: ~20 minut (głównie pobieranie .NET + reboot). Pełen runbook → infra/scripts/windows-kiosk/RUNTIME.md.

Versioning kiosku

lib/core/version.dart exports kAppVersion. OTA agent reportuje to do backendu po udanej instalacji (POST /machines/me/release-assignment/:id/status, installedVersion) — Machine.currentVersion zostaje w sync.

Etap kAppVersion
3.0 (skeleton) 0.30.0
3.1 (release backend) 0.31.0
3.2 (panel UI) 0.32.0
3.3 + 3.4 (Flutter scaffold + OTA) 0.34.0
3.5 (pickup flow) 0.35.0
3.5 polish 1–12 0.36.00.47.0
3.5 polish 12 final 0.48.0

Aktualny live: 0.83.0 (etap-5 fixes).

Co się zmieniło od etapu 3

  • Kiosk hardware wszedł w etapie 5 — ModbusLockerHardware zamiast mock-a, lifecycle hardening (deposit-into-open guard, PIN clear on fail, defensive close-verify, opening watchdog). Patrz Etap 5.
  • Offline mode wszedł w etapie 4.5 — SyncService co 2 min ciągnie cached_users / cached_credentials / cached_lockers / cached_shipments i replay-uje outbox. Patrz Etap 4 · Offline.
  • Mobile-parity API wszedł w etapie 4.6 — me-shipments JWT-authed endpoints jako foundation pod przyszłe mobilki Android. Patrz Etap 4 · Mobile API.
  • OTA agent buffer + throttle zoptymalizowany w bbfefd4 (bigger buffer + rare status reports = 140x faster) — z 256 B → 256 KB buffer, status report z każdego packetu → co 5 s.
  • RFID HID-keyboard auth wszedł w etapie 5 polish (fc23bf4d991582) — pięć fallback-ów, ostatecznie WH_KEYBOARD_LL hook w PS bridge.
  • Cache-first hot paths (etap 5 polish, 59690bf) — login/menu/pickup czyta z cached_* zanim odpyta backend, fallback po 200 ms.

Pełna mapa screens + offline cache + OTA agent → Kiosk (Flutter).