# Panneau d'administration TRMNL « ITA ITO » — plan d'implémentation ## Context La boutique ITA ITO (tricot, Genève) veut afficher ses horaires d'ouverture sur un écran e-ink TRMNL 7.5" (kit DIY Seeed, XIAO ESP32-S3 Plus, 800×480 monochrome) posé en vitrine. Le besoin réel : **changer un horaire depuis un téléphone, depuis la boutique, en deux clics** (fermeture exceptionnelle, ouverture retardée, congés), et que l'écran suive tout seul — y compris les jours fériés genevois et un message libre bilingue FR/EN. Il n'existe rien aujourd'hui : `/home/vl/docker/horaire_boutique` est vide. On construit l'application de zéro. ### Décisions prises pendant le cadrage (elles modifient la spec d'origine) | Sujet | Spec initiale | Décision retenue | Pourquoi | |---|---|---|---| | Mode TRMNL | BYOD + Private Plugin | **BYOS auto-hébergé** | Pas de licence BYOD à payer, pas de limite 2 Ko / 12 req-h, pas de dépendance au cloud TRMNL. Seeed et TRMNL confirment : « BYOS, no license required, for any TRMNL compatible device ». | | Rendu de l'écran | Template Liquid rendu par TRMNL | **Rendu PNG/BMP 1-bit par notre app** | Conséquence directe du BYOS : il n'y a plus de serveur TRMNL pour interpréter du Liquid. | | Webhook + debounce | §1 | **Supprimés** | En BYOS on ne pousse rien : l'appareil dort et vient chercher. Le seul levier est `refresh_rate`. | | `trmnl-plugin/`, `settings.yml`, `full.liquid` | §1 | **Supprimés** | Sans objet en BYOS. | | Stack | Next.js 15 | **Next.js 16 + Auth.js v5 + Prisma + Postgres 16** | Recopie le branchement Authentik/OIDC déjà en production dans `api_llm_loxi`, et les tokens visuels de `model_ita_ito`. | | Vacances | `VacationPeriod` matérialise des `ScheduleException` | **Résolution à la volée** | `VacationPeriod` reste seule source de vérité, lue au rang 2 du resolver. Aucun risque d'écraser une exception `MANUAL`, aucun orphelin quand on modifie une période. | | Traduction | API Anthropic- ou OpenAI-compatible | **Adaptateur `api.loxi.ch` natif** | Ni l'un ni l'autre : c'est ton `api_llm_loxi`, `POST /api/generate {model_id, prompt}` → `{stdout, exit_code, …}`. | | Rafraîchissement | 15 min fixe | **Adaptatif** | 10 min en heures d'ouverture, 2 h la nuit et jours fermés. Autonomie vs fraîcheur. | | Seed | Horaires réels | **Seed générique Mar–Sam 10:00–18:30**, corrigé depuis l'UI | — | ### Ce qui reste vrai de la spec Le cœur métier ne bouge pas : moteur de résolution pur et testé à fond, contrat `screen.json` figé en `schema: 1` et verrouillé par snapshot, UI d'admin en français et responsive, auth Authentik avec rôles, journal d'audit, fériés OpenHolidays, traduction FR→EN avec cache, Docker dev/prod, CI bloquante. --- ## Architecture Un seul service applicatif + Postgres. L'écran et l'humain attaquent la même app, par deux chemins qui ne se croisent jamais. ``` Écran e-ink (ESP32, LAN boutique) │ GET /api/setup header ID: │ GET /api/display header Access-Token, Battery-Voltage, RSSI, FW-Version… │ POST /api/log │ GET /api/device/image/.bmp ← non devinable, immuable, cacheable ▼ ┌─────────────────────────────────────────────────────┐ │ Next.js 16 (App Router, TS strict) horaires.ita-ito.com │ │ │ │ lib/schedule/resolver.ts ← pur, sans I/O, 100 % testé │ lib/screen/viewmodel.ts ← Prisma → ScreenPayload (schema 1) │ lib/screen/render.ts ← ScreenPayload → SVG → PNG → BMP 1-bit │ lib/device/refresh.ts ← refresh_rate adaptatif │ lib/translation/ ← TranslationProvider + adaptateur loxi │ lib/holidays/ ← OpenHolidays, upsert idempotent │ app/admin/** ← UI française, protégée Auth.js └─────────────────────────────────────────────────────┘ ▲ │ │ OIDC (auth.loxi.ch) │ POST /api/generate (Bearer llk_…) Navigateur admin api.loxi.ch (traduction FR→EN) │ openholidaysapi.org (fériés CH-GE) ``` **Le pipeline d'affichage est une chaîne de fonctions pures**, c'est ce qui rend le tout testable : ``` Prisma ──► ScheduleContext ──► resolveWeek/getCurrentStatus ──► ScreenPayload (JSON figé) │ ┌─────────────────────────────────┤ ▼ ▼ renderSvg() → PNG → BMP 1-bit aperçu HTML /admin (même SVG) │ hash du contenu = filename ``` Le `filename` renvoyé à l'appareil **est le hash du contenu**. Le firmware compare le nom de fichier avec celui qu'il a en cache : contenu identique → pas de redraw → batterie économisée. C'est gratuit et c'est le gain d'autonomie le plus important du projet. ### Rendu de l'image — choix technique `satori` (HTML/flexbox → SVG, moteur Yoga) puis `@resvg/resvg-js` (SVG → PNG), puis un encodeur BMP 1-bit maison (~50 lignes : en-têtes BITMAPFILEHEADER/INFOHEADER, palette 2 couleurs, lignes paddées à 4 octets) avec seuillage sans tramage. Pourquoi pas Chromium headless : ajouterait ~500 Mo et une consommation mémoire sérieuse à l'image runtime pour un écran de 8 blocs. Playwright reste présent pour l'E2E, mais dans son image dédiée, jamais dans l'image applicative. Bénéfice test majeur : **on snapshot la chaîne SVG**, pas un binaire. Un diff de rendu est lisible en revue. Le binaire n'est vérifié que sur ses invariants (dimensions, profondeur, taille). Format servi : `DEVICE_IMAGE_FORMAT=bmp|png`, **défaut `bmp`** (le firmware Seeed référence des `.bmp`). Bascule par variable si l'appareil réclame du PNG. --- ## Modèle de données (Prisma) Reprend la §4, moins ce que le BYOS rend caduc, plus ce qu'il impose. ```prisma Settings id @default("singleton") // ligne unique shopName, timezone ("Europe/Zurich") countryIsoCode ("CH"), subdivisionCode ("CH-GE") defaultClosedMessageFr / En refreshRateOpenSec (600), refreshRateClosedSec (7200) imageFormat ("bmp"), updatedAt WeeklySchedule dayOfWeek @unique (0=dimanche..6), isClosed, slots Json // [{"open":"10:00","close":"13:00"},…] max 3 plages ScheduleException date @unique, isClosed, slots Json? reason (TEMPORARY|HOLIDAY|VACATION|SPECIAL_EVENT) noteFr, noteEn, source (MANUAL|HOLIDAY_API) createdBy, createdAt VacationPeriod startDate, endDate, labelFr, labelEn, createdBy // source de vérité, JAMAIS matérialisée en exceptions PublicHoliday @@unique([date, subdivisionCode]) nameFr, nameEn, nationwide, source, syncedAt, isAutoClosed Message textFr, textEn, translationStatus (PENDING|DONE|MANUAL|ERROR) startsAt, endsAt?, priority, isActive, createdBy TranslationCache @@unique([sourceHash, targetLang, model]) sourceText, translatedText, createdAt AuditLog userEmail, action, entity, entityId, diff Json, createdAt // — ajouts imposés par le BYOS — Device macAddress @unique, friendlyId, apiKeyHash label, isActive lastSeenAt, fwVersion, batteryVoltage, percentCharged, rssi lastFilename, lastRefreshRate DeviceLog deviceId, level, message, payload Json, createdAt // rétention courte, purgée par le job quotidien SyncState key @unique ("holidays"), lastSuccessAt, lastErrorAt, lastError ``` `apiKeyHash` : on stocke un SHA-256, jamais le jeton en clair — même discipline que les clés `llk_…` de `api_llm_loxi`. ### Moteur de résolution — `lib/schedule/resolver.ts` Module pur, aucune I/O, aucun accès Prisma. Signature de la §4 conservée : ```ts resolveDay(date: Date, ctx: ScheduleContext): ResolvedDay resolveWeek(fromDate: Date, ctx: ScheduleContext): ResolvedDay[] getCurrentStatus(now: Date, ctx: ScheduleContext): ShopStatus ``` Priorité : `ScheduleException` MANUAL → `VacationPeriod` → `PublicHoliday` si `isAutoClosed` → `WeeklySchedule` → fermé. **Heure locale, jamais d'UTC.** Les créneaux sont des chaînes `"HH:mm"` en heure murale Europe/Zurich. On ne fait aucune arithmétique sur des instants UTC pour décider d'une plage : c'est ce qui rend les changements d'heure de mars et d'octobre non-événements. `date-fns-tz` sert uniquement aux conversions aux frontières (now → date locale, formatage). `nextOpening` : balayage borné à **14 jours**, retourne `null` au-delà. Une boutique fermée toute l'année ne doit pas faire boucler le serveur — c'est un cas de test explicite. Bandeau automatique : divergence sur aujourd'hui seul → « Changement d'horaire aujourd'hui », divergence ailleurs dans les 7 jours → « Horaires exceptionnels cette semaine ». Libellés FR+EN en dur, aucun appel API. Un message libre actif l'emporte sur le bandeau automatique. --- ## Fichiers à créer ``` PLAN.md README.md DEPLOY.md LICENSE .env.example .gitignore Dockerfile docker-compose.yml docker-compose.prod.yml docker-entrypoint.sh .github/workflows/ci.yml prisma/schema.prisma prisma/seed.ts lib/schedule/resolver.ts ← cœur métier, pur lib/schedule/validate.ts ← plages : format, ordre, chevauchement, max 3 lib/schedule/format.ts ← dates/heures FR + EN, Europe/Zurich lib/schedule/context.ts ← Prisma → ScheduleContext (seule couche I/O) lib/screen/contract.ts ← types ScreenPayload, SCHEMA_VERSION, MESSAGE_MAX_CHARS lib/screen/viewmodel.ts ← ScheduleContext → ScreenPayload lib/screen/render.tsx ← ScreenPayload → SVG (satori) lib/screen/encode.ts ← SVG → PNG (resvg) → BMP 1-bit seuillé lib/device/auth.ts ← Access-Token, comparaison temps constant lib/device/refresh.ts ← refresh_rate adaptatif lib/translation/provider.ts ← interface TranslationProvider lib/translation/loxi.ts ← adaptateur api.loxi.ch lib/translation/service.ts ← cache sha256, retries, statuts lib/holidays/openholidays.ts ← client FR + EN, retry exponentiel lib/holidays/sync.ts ← upsert idempotent lib/audit.ts lib/auth.ts lib/ratelimit.ts lib/db.ts app/api/setup/route.ts ← device API app/api/display/route.ts app/api/log/route.ts app/api/device/image/[hash]/route.ts app/api/health/route.ts app/api/admin/**/route.ts app/(auth)/login/page.tsx app/admin/page.tsx ← tableau de bord + aperçu 800×480 app/admin/horaires/page.tsx app/admin/exceptions/page.tsx app/admin/vacances/page.tsx app/admin/feries/page.tsx app/admin/messages/page.tsx app/admin/parametres/page.tsx app/styles/tokens.css ← copié de model_ita_ito middleware.ts instrumentation.ts ← node-cron démarré ici scripts/brand.ts ← logo → logo.png + logo-eink.png (1-bit, 260px) scripts/preview.ts ← npm run screen:preview → aperçu 800×480 local public/brand/ ← logos générés, COMMITÉS e2e/*.spec.ts ← Playwright ``` ### Réutilisation de l'existant - **`/home/vl/docker/model_ita_ito/frontend/src/styles/tokens.css`** — copié tel quel. Palette (`--canvas #faf9f5`, `--accent #b5552f`, `--ink #141413`), rayons 6/8/12 px, Inter Variable + Source Serif 4 pour les titres, thème sombre complet, contrastes déjà validés WCAG AA. C'est la continuité visuelle demandée en §7, gratuite. - **`/home/vl/docker/api_llm_loxi/frontend/src/auth.ts`** — le branchement Auth.js v5 ↔ Authentik (issuer, claim `groups`, callbacks) est en production ; on le reprend au lieu de le réinventer. - **`/home/vl/docker/model_ita_ito/docker-compose.prod.yml`** — les labels Traefik, l'ancre `x-hardening` (`no-new-privileges`, `cap_drop: ALL`, `read_only`) et le réseau `edge` externe sont le patron de déploiement du VPS. - **`/home/vl/docker/api_llm_loxi/docs/API.md`** — contrat exact de la traduction. --- ## API appareil (BYOS) | Route | Entrée | Sortie | |---|---|---| | `GET /api/setup` | header `ID: ` | `{status, api_key, friendly_id, image_url, message}` — crée le `Device` au premier contact ; si déjà provisionné, `api_key` vide | | `GET /api/display` | `Access-Token`, `ID`, `Battery-Voltage`, `Percent-Charged`, `RSSI`, `FW-Version`, `Width`, `Height` | `{image_url, filename, refresh_rate, update_firmware:false, reset_firmware:false, special_function:"none", image_url_timeout:0}` | | `POST /api/log` | `Access-Token` + `{logs:[…]}` | `204` | | `GET /api/device/image/.bmp` | — | l'image, `Cache-Control: immutable` | **Appairage** (README, pas de reflash) : maintenir le bouton ~5 s → portail captif → Wi-Fi → *Advanced > Custom Server > Yes* → `https://horaires.ita-ito.com` **sans slash final**. `refresh_rate` adaptatif, calculé à chaque `/api/display` : `refreshRateOpenSec` (600) si la boutique est ouverte ou ouvre dans l'heure, `refreshRateClosedSec` (7200) sinon, et raccourci pour tomber juste après le prochain changement d'état (ouverture/fermeture) plutôt que de le manquer de 9 minutes. ### Risques identifiés, à vérifier sur l'appareil 1. **Champ « Custom Server »** — le firmware TRMNL officiel l'expose sous *Advanced*, mais le README du firmware Seeed livré sur le kit référence un backend `usetrmnl.com` fixe et ne le documente pas. Si le portail captif ne propose pas le champ, il faudra flasher le firmware TRMNL officiel — ce qui contredit la contrainte §1. **À vérifier avant l'incrément 5**, c'est le seul point qui peut remettre en cause l'architecture. 2. **TLS sur ESP32** — tu as choisi le VPS public en HTTPS. Ces firmwares trébuchent parfois sur les chaînes de certificats. Plan de repli prévu dès le début : un vhost HTTP dédié à l'appareil (`DEVICE_ALLOW_HTTP=true`), token d'appareil distinct et révocable, URL d'image non devinable. 3. **BMP vs PNG** — indécidable depuis la doc. Les deux encodeurs sont écrits, `DEVICE_IMAGE_FORMAT` bascule, on tranche sur l'appareil réel. --- ## Traduction FR → EN via `api.loxi.ch` Contrat réel (lu dans `api_llm_loxi/docs/API.md`) : ``` POST {TRANSLATION_API_URL}/api/generate Authorization: Bearer llk_… {"model_id": 5, "prompt": ""} → 200 {"stdout":"…", "stderr":"…", "exit_code":0, "duration_ms":2774} ``` Trois pièges, traités explicitement : - **`model_id` est un entier, pas un nom.** On ne le code pas en dur : au démarrage on résout `TRANSLATION_MODEL_NAME=haiku` via `GET /api/models?provider=claude&active=true`, résultat mis en cache. L'alias `haiku` pointe toujours vers la dernière version. - **Un échec CLI renvoie quand même `200`.** On vérifie `exit_code !== 0` → `ERROR`. Ne jamais se fier au code HTTP seul. - **Le backend lance réellement le CLI Claude Code** (`CLI_TIMEOUT_SECONDS=300` chez toi). Le timeout de 10 s de la §6 est irréaliste : **30 s**, 2 retries. Le reste conforme à la §6 : interface `TranslationProvider` (les adaptateurs `anthropic`/`openai` restent possibles derrière `TRANSLATION_API_FLAVOR`, non écrits tant qu'inutiles), cache `sha256(FR)`, traduction asynchrone non bloquante, `MANUAL` jamais écrasé tant que le FR ne change pas, badge + bouton « Réessayer ». --- ## Sécurité - Pages `/admin/**` et routes `/api/admin/**` : middleware Auth.js, aucune exception. Rôles depuis le claim `groups` : `AUTHENTIK_ADMIN_GROUP` → `admin`, sinon `viewer` (lecture seule). - Routes appareil : `Access-Token` comparé avec `crypto.timingSafeEqual`, rate-limit par IP et par appareil. Ce sont les seules routes hors OIDC. - `/api/device/image/` : hash de contenu non devinable, pas d'énumération possible. - Aucun secret en dur, aucun secret en base en clair. `.env` hors Git. - `AuditLog` sur chaque écriture admin : utilisateur, action, entité, diff JSON, horodatage. ### Variables d'environnement (`.env.example`) ``` DATABASE_URL POSTGRES_USER / PASSWORD / DB APP_DOMAIN # ex. horaires.ita-ito.com — callback OIDC + image_url ← À CONFIRMER NEXTAUTH_URL NEXTAUTH_SECRET AUTHENTIK_ISSUER # https://auth.loxi.ch/application/o// ← À CONFIRMER AUTHENTIK_CLIENT_ID AUTHENTIK_CLIENT_SECRET AUTHENTIK_ADMIN_GROUP ← À CONFIRMER TRANSLATION_API_URL # https://api.loxi.ch TRANSLATION_API_KEY # llk_… ← À CONFIRMER TRANSLATION_MODEL_NAME=haiku TRANSLATION_API_FLAVOR=loxi TRANSLATION_TIMEOUT_MS=30000 HOLIDAYS_API_URL=https://openholidaysapi.org DEVICE_IMAGE_FORMAT=bmp DEVICE_ALLOW_HTTP=false TZ=Europe/Zurich ``` Les quatre « À CONFIRMER » sont documentés dans `.env.example` et demandés au moment où l'incrément correspondant démarre — jamais devinés. --- ## Jobs planifiés `node-cron` démarré depuis `instrumentation.ts` (un seul conteneur applicatif, pas de coordination multi-instance à prévoir). Choix documenté dans le README, conformément à la §2. - **03:00 Europe/Zurich** — synchro OpenHolidays sur la fenêtre glissante `[J, J+1 an]`, deux appels (`languageIsoCode=FR` puis `EN`), upsert idempotent, retry exponentiel ×3, `SyncState` mis à jour. Une exception `MANUAL` n'est jamais écrasée. - **03:15** — purge des `DeviceLog` de plus de 30 jours. - Déclenchement manuel : `POST /api/admin/holidays/sync` + bouton dans `/admin/feries`. En cas d'indisponibilité de l'API : on garde le cache local, on logue, l'UI affiche la date de dernière synchro réussie. Jamais d'échec silencieux. --- ## Tests Seuil : **≥ 85 % sur `lib/schedule/**` et `lib/screen/**`**, CI bloquante. **Unitaires (Vitest)** — `resolver.ts` avec table de cas exhaustive : jour ouvert/fermé, plages multiples, exception MANUAL qui prime sur un férié, vacances qui priment sur l'horaire fixe, férié non fermant, **semaine entièrement fermée (`nextOpening` ne boucle pas)**, changements d'heure des derniers dimanches de mars et d'octobre, passage d'année, bornes 23:59 / 00:00. Plus : validation des plages, formatage FR/EN, détection du bandeau automatique, `refresh_rate` adaptatif, encodeur BMP 1-bit (en-têtes, padding, seuillage). **Intégration** — `/api/admin/**` : 401 sans session, 403 `viewer`, 200 `admin`. `/api/display` : 401 sans `Access-Token`, 200 avec, `filename` stable quand le contenu ne change pas (le test qui protège la batterie). **Snapshot du `ScreenPayload`** : c'est lui qui bloque les régressions de contrat. Snapshot du SVG rendu. Fériés sous MSW : nominal, vide, 500, timeout, idempotence (2 synchros → mêmes données). Traduction sous MSW : succès, `exit_code != 0` malgré un HTTP 200, cache (2 appels → 1 requête), `MANUAL` non écrasé. **E2E (Playwright, auth mockée)** — modifier l'horaire du jour → le payload le reflète ; créer un message → traduction affichée → présent dans le payload ; ajouter des vacances → les jours concernés passent fermés. Plus une passe axe (a11y) et un rendu iPhone. --- ## Ordre d'implémentation Un incrément = tests verts + commit Conventional Commits en anglais. Aucune tâche ne démarre sur des tests rouges. | # | Incrément | Sortie vérifiable | |---|---|---| | 0 | Repo, Next.js 16 TS strict, Tailwind 4 + `tokens.css`, ESLint, CI verte, `PLAN.md` | `npm run lint && npm run typecheck` | | 1 | Schéma Prisma + migration + seed générique | `prisma migrate dev`, seed idempotent | | 2 | **`resolver.ts` + sa batterie de tests** — avant toute UI | couverture ≥ 85 % sur `lib/schedule` | | 3 | `contract.ts` + `viewmodel.ts` + snapshot du payload | snapshot commité | | 4 | `render.tsx` + `encode.ts` + `npm run screen:preview` | BMP 800×480 1-bit ouvrable | | 5 | `/api/setup`, `/api/display`, `/api/log`, image servie, `refresh_rate` adaptatif | **appairage réel de l'écran** | | 6 | Auth Authentik, middleware, rôles, `AuditLog` | 401/403/200 testés | | 7 | UI : horaires → exceptions → vacances → messages → tableau de bord → paramètres | utilisable au téléphone | | 8 | Traduction loxi + cache + tests MSW | — | | 9 | Synchro OpenHolidays + cron quotidien | idempotence prouvée | | 10 | Docker dev + prod, `README.md`, `DEPLOY.md` | `docker compose up` de zéro | | 11 | E2E Playwright + passe a11y et responsive | suite verte | L'incrément 5 est le jalon de vérité : c'est là qu'on saura si le firmware du kit accepte un serveur personnalisé. Il est placé tôt exprès, avant d'avoir investi dans l'UI. Git : branches `feat/*`, PR vers `main`, CI bloquante. Repo privé `ita-ito-trmnl-admin` créé via `gh repo create` — **je demanderai confirmation avant le premier push**. --- ## Vérification de bout en bout ```bash # 1. Stack complète depuis zéro docker compose up -d --build --wait docker compose exec app npx prisma migrate deploy && npm run seed # 2. Qualité npm run lint && npm run typecheck && npm run test -- --coverage # 3. Contrat d'affichage, sans appareil npm run screen:preview # → aperçu 800×480 dans le navigateur curl -s localhost:3010/api/display -H "Access-Token: " | jq curl -s localhost:3010/api/device/image/.bmp -o /tmp/screen.bmp file /tmp/screen.bmp # attendu : PC bitmap, 800 x 480 x 1 # 4. Non-régression horaires (le test qui compte) # modifier l'horaire du jour dans /admin/horaires, puis : # le ScreenPayload change, le filename change, le refresh_rate suit # 5. E2E npx playwright test ``` Validation finale sur l'appareil : appairage via le portail captif, l'écran affiche les horaires du jour, une modification faite depuis un téléphone apparaît au réveil suivant. --- ## Points à confirmer 1. **Domaine public** de l'app sur le VPS (`horaires.ita-ito.com` ?) — nécessaire au callback OIDC et à l'`image_url` servie à l'appareil. 2. **Slug de l'application Authentik** et **nom du groupe admin**. 3. **Clé API `llk_…`** pour `api.loxi.ch`, et confirmation que l'instance est bien joignable depuis le VPS (ton `docker-compose.yml` la borne à `127.0.0.1` en local). 4. **Nom du repo GitHub privé** : `ita-ito-trmnl-admin` ? Aucun de ces quatre points ne bloque les incréments 0 à 4. Je les demanderai au moment voulu ; ils sont déjà documentés dans `.env.example`.