# tasmofleet design Status: accepted for implementation, 2026-10-18. Owner: the maintainer. Author: architect agent. The code skeleton in `internal/` implements the contracts in this document. If code and spec disagree, fix one of them in the same change. Inputs: device captures in `testdata/fixtures/` (Tasmota 16.0.0, 6 devices, 6.7 min of MQTT), `docs/research/tasmota-minimal-and-ota.md` (minimal firmware and OTA flow, derived from the v16.0.0 source), and `docs/research/tasmoadmin-*.md` (feature inventory, data formats, lessons learned). Labels used below: **OBS** = observed on real devices, **SRC** = read from Tasmota source, **DEC** = design decision. --- ## 1. Purpose and scope tasmofleet manages a fleet of Tasmota devices and replaces TasmoAdmin with feature parity. Home Assistant keeps automation. tasmofleet does inventory, live state, health, firmware updates, configuration backups, device settings and fleet-wide commands. ### 1.1 Goals 1. **Safe firmware updates.** They run on the server as a persisted state machine and survive restarts. A hard guard refuses any image for the wrong chip family, and 1 MB ESP8266 devices are staged through minimal firmware based on free flash, not on the model name. 2. **HTTP is enough.** Every feature works without MQTT. MQTT only makes health and live state faster. 3. **Usable backups.** The raw `.dmp` is the authority. Decoded JSON is added for diffs and drift checks. 4. **First-class SSO.** Native OIDC (code + PKCE, discovery, groups, RP-initiated logout) or trusted forward-auth headers, both correct behind reverse proxies, and a full standalone mode (local accounts, own TLS) for hosts without proxy or IdP. 5. **Simple to operate.** One static binary, SQLite, one OCI image, config through env and file, structured logs, Prometheus metrics. Ready for a Home Assistant add-on. 6. **AI-maintainable.** Small single-purpose packages with fixed interfaces, enforced boundaries, a device simulator, and gates that catch regressions (mutation tests on the safety-critical code). ### 1.1a Principles (product decisions, 2026-10-19) 1. **Minimal mandatory configuration.** A fresh install starts with almost no settings. Defaults: self-signed TLS, local auth with a first-run admin and a first-run wizard. The environment holds only bootstrap values (data dir, listeners, and the things needed before the database is open). Everything else, SSO/OIDC included, is adjusted live in the UI and stored in the database. An environment value still locks its field. Legacy environment values are imported into the database once. 2. **Proportionate security for a homelab tool.** Keep the solid basics: CSRF protection, authorisation on every route, no secrets in logs or HTML, the chip-family guard, and secrets sent only to the address they are bound to. Step-up (re-authentication) only for local account management, auth/SSO changes and restore; routine settings don't need it. Security reviews: High and Medium findings are fixed; Low and Info findings become documented residual risks without further review rounds. 3. **The reference deployment is not a design constraint.** Network specifics of one installation (e.g. firewall rules between IoT VLANs and the Home Assistant host) are handled in its deployment docs. The Home Assistant add-on (M3) targets other users. ### 1.2 Non-goals - Automations, schedules, timers, scenes (Home Assistant). - A switching dashboard beyond a per-device power toggle in the device list (M2). - Self-update of tasmofleet, passkeys/WebAuthn, open self-registration, an "auth off" mode. - Self-service registration or password reset by e-mail. - Building custom Tasmota firmware. Uploading custom builds is in scope for M2 (§8.6). - Serial flashing / first install of Tasmota. - Multi-tenant or multi-instance clustering. ### 1.3 Milestones (summary, details in §23) | M | Content | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | M1 core | inventory (manual + TasmoAdmin import), health (HTTP poll + optional MQTT), firmware cache + firmware server, OTA engine, backups (raw + decoded), auth (OIDC, header, local accounts, standalone TLS), audit, metrics, image, Unraid template, Grafana dashboard | | M2 parity | device list with live state + power toggle, device settings editing, console/commands, bulk commands, autoscan (HTTP) + MQTT discovery, policy/drift view, backup diff + restore, custom firmware upload, notifications | | M3 | Home Assistant add-on (ingress), move to public GitHub | --- ## 2. Example deployment This is the kind of installation tasmofleet is built for. The code stays generic. Concrete values appear only in `deploy/` examples and in this section (all names and addresses are placeholders). | Aspect | Example | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Host | A Docker host with a macvlan/ipvlan network and fixed container IPs in 172.31.12.0/24. An existing TasmoAdmin runs at 172.31.12.60; tasmofleet gets its own fixed IP (172.31.12.61) and can take over .60 when TasmoAdmin is retired (§22) | | Container | Unraid template `deploy/unraid/tasmofleet.xml` (any Docker host works), data directory mounted at `/data` | | Reverse proxy | Traefik v3, single entrypoint `websecure` on 172.31.12.20. Public name `tasmofleet.example.com`, internal split DNS `tasmofleet.example.internal`. `TRUSTED_PROXIES=172.31.12.20/32` | | SSO | Authentik at `auth.example.com`, one OAuth2/OIDC provider per app. Issuer `https://auth.example.com/application/o/tasmofleet/`. Groups `Tasmofleet Users` (operator), `Tasmofleet Admins` (admin). Providers created through the API need `grant_types` authorization_code + refresh_token. Redirect URI `https://tasmofleet.example.com/oidc/callback` | | Devices | IoT VLANs 172.31.13.0/24 and 172.31.23.0/24, a ~20 device fleet: Sonoff Basic / MagicHome (ESP8285N08), Gosund SP111 / NOUS A1T (ESP8266EX), all ESP8266 with 1 MB flash, plus ESP32-D0WDQ6 lamps (tasmota32). All on 16.0.0, image `release-tasmota` / `release-tasmota32`. Device names `-` = MQTT topic = hostname. No web passwords | | Firewall | IoT may reach only MQTT (172.31.12.11:1883) and the OTA server, TCP 80. If the rule is IP-based, the firmware server must listen on **plain HTTP port 80 on the container IP** (`FIRMWARE_LISTEN=:80`, `FIRMWARE_BASE_URL=http://172.31.12.61`) | | MQTT | `mqtt.example.internal:1883` (Mosquitto), user `tasmota`. Tasmota discovery retained on `tasmota/discovery/+/config`. TelePeriod 300, FullTopic default `%prefix%/%topic%/` | | Observability | Prometheus scrapes `/metrics`; blackbox probes `/healthz`. A log collector (e.g. Loki via Alloy) reads Docker JSON logs, so tasmofleet logs JSON to stdout. tasmofleet ships `deploy/grafana/tasmofleet.json` | | Notifications | optional, later: mail, or a Home Assistant webhook (§14.5) | | Fleet policy | PowerOnState 0, SetOption0 0 (`save_state`), SaveData 0, Timezone 99 with DST rules, NTP from DHCP. Exception: ESP32 lamps use SetOption0 1. M2 policy view flags drift (§14.4) | | CI | GitHub Actions on GitHub-hosted runners. Images go to `ghcr.io/nofuturekid/tasmofleet` | Example environment for this deployment: `deploy/unraid/tasmofleet.env.example`. --- ## 3. Architecture ### 3.1 Process view ``` Traefik (websecure, TLS) Authentik (OIDC / forward auth) | | v | +------------------------------- tasmofleet ---------------------------------+ | UI listener :8080 (auth, CSRF, HTMX pages, SSE, /metrics, /healthz) | | | | | web --> inventory, health, ota.Engine, backup, firmware.Cache, audit | | | | | | | | | store (SQLite /data/tasmofleet.db, WAL) files /data/... | | | | | | | mqttbus (optional) tasmota.Client (HTTP, SSRF-guarded) | | | | Firmware listener :80 (plain HTTP, GET/HEAD /fw// only) | +--------------|--------------------------------|----------------------------+ | MQTT 1883 (optional) | HTTP 80 to devices (/cm, /dl, /) v v Mosquitto <------- tele/stat ----- Tasmota devices (IoT VLANs) | HTTP GET firmware (port 80) +-----> firmware listener ``` There are two listeners on purpose. Devices reach only the firmware listener, which has no authentication, no UI and no state-changing routes. People reach only the UI listener, through the reverse proxy. ### 3.2 Package map and contracts Every package has one purpose and can be tested in isolation. The skeleton contains each interface listed here. Imports point downwards: `model` is the leaf and `app` wires everything. | Package | Purpose | Key exported API (contract) | Depends on | | --------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | | `model` | domain types | `Device`, `Identity`, `ChipFamily`, `MQTTCapability`, `Health`, `Artifact`, `Job`, `JobState`, `Stage`, `Plan`, `UpdatePolicy`, `Backup`, `AuditEvent`, `Principal`, `Role` | stdlib | | `config` | configuration | `Config`, `Load(environ)` | stdlib | | `clock` | testable time | `Clock{Now, After}`, `Real` | stdlib | | `secretbox` | AES-GCM at rest | `Box{Seal, Open}`, `LoadOrCreate(dataDir)` | stdlib | | `store` | persistence | `DeviceStore`, `HealthStore`, `JobStore`, `ArtifactStore`, `BackupStore`, `AuditStore`, `SessionStore`, `LocalUserStore`, `Store`, `Open(path, box)`, `Migrations` | model, secretbox, modernc.org/sqlite | | `tasmota` | device HTTP protocol | `Client{Command, DownloadConfig, RootPage}`, `Target`, `TargetFor(Device)`, `ParseStatus2/4/11`, `ParseUpgradeProbe`, `ParseOtaURL`, errors `ErrUnknownCommand`, `ErrAuth`, `ErrUnreachable`, `ErrTimeout`, `ErrForbiddenAddr` | model | | `fwselect` | **pure** firmware decisions | `Detect`, `FamilyFromHardware`, `Select`, `Decide`, `InspectImage`, `Guard`, `Plan`, errors `ErrUnknownHardware`, `ErrFamilyMismatch`, `ErrFilenameMismatch`, `ErrSafeboot`, `ErrVariantChange`, `ErrNoSuchVariant`, `ErrBadImage` | model | | `firmware` | upstream sources, cache, device-facing server | `Cache{Releases, Ensure, URLFor, Prune}`, `Source`, `Server(Cache)` | model, fwselect, store | | `ota` | update state machine + engine | `Step(job, event, now, cfg)` (pure), `Engine{Run, Submit, Preview, Cancel}`, `DeviceAPI`, `Firmware`, `Backups`, `Notifier` | model, fwselect, store, clock | | `mqttbus` | optional MQTT | `Bus{Subscribe, Command, Connected}`, `Connect`, `Message`, `Kind` | paho | | `health` | reachability | `Tracker{Get, All, Run}`, `Options` | model, mqttbus, tasmota, store, events | | `events` | in-process pub/sub | `Hub{Publish, Subscribe}`, `Event`, `Topic` | stdlib | | `inventory` | device service | `Service{List, Get, Create, Update, Delete, Upsert}` | model, store, audit, events | | `tasmoadmin` | import parsers | `ParseDevices(r, key)`, `ParseConfig(r)` | model | | `backup` | backups | `Service{Backup, List, Open}`, `Decoder{Version, Decode}` | model, store, tasmota | | `audit` | audit trail | `Recorder{Record}`, `RedactKeys` | model, store | | `auth` | authentication | `Authenticator{Principal, Routes}`, `RoleMapping`, `HashPassword`, `VerifyPassword`, `FromContext`, `WithPrincipal` | model, store, httpx, go-oidc, oauth2 | | `httpx` | HTTP plumbing | `Proxy{Trusted, Middleware}`, `BasePath(ctx)` | stdlib | | `web` | UI | `Handler(Deps)`, `OTA`, `Assets` | interfaces of the above | | `metrics` | Prometheus text format | `Registry`, `Handler` | stdlib | | `app` | wiring, lifecycle | `Run(ctx, cfg)` | everything | | `simulator` | Tasmota simulator (test only) | `NewFleet`, `Fleet`, `Device`, `DeviceSpec`, `Profile`, `Fault` | model, mochi-mqtt (embedded broker) | | `testutil/oidcmock` | mock IdP (test only) | `Provider{Issuer, Handler, NextLogin}` | stdlib | | `discovery`, `bulk`, `policy`, `notify` | M2 | see package docs | | Boundaries are enforced in `.golangci.yml` (depguard) and listed in `CLAUDE.md`. ### 3.3 Dependencies Each module needs a reason. Anything not listed needs a spec change first. | Module | Why | Scope | | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | | `modernc.org/sqlite` | SQLite without CGO, so the binary is static and the image distroless | prod | | `github.com/eclipse/paho.mqtt.golang` | mature MQTT 3.1.1 client (Tasmota speaks 3.1.1) with auto-reconnect; writing MQTT by hand is not worth the risk | prod | | `github.com/coreos/go-oidc/v3` | ID token verification, JWKS rotation, discovery; security-critical, do not hand-roll | prod | | `golang.org/x/oauth2` | authorization code exchange with PKCE (`oauth2.S256ChallengeOption`) | prod | | `github.com/mochi-mqtt/server/v2` | embedded MQTT broker for tests and `tasmofleet-devenv` only (depguard keeps it out of prod packages) | test | | `github.com/a-h/templ` | typed HTML components for the web UI (design v2 port, plan section 4): template errors become compile errors, props are typed. The runtime is a small package; the generator is pinned in `tools/go.mod` and its output (`*_templ.go`) is committed, so the build and the Dockerfile need no generator (`make templ`, `make templ-check`). templ imports only in `internal/web` and `internal/web/ui` (depguard); `templ.Raw`, script and css components are forbidden (CSP) | prod | | htmx 2.0.11 + htmx-ext-sse 2.2.4 | vendored JS files, no npm in the build | prod (static) | | `@playwright/test` 1.64.1 | e2e only, in `e2e/` | test | | `pixelmatch` 5.3.0, `pngjs` 7.0.0 (+ `@types`) | pixel comparison against the design prototype (`make visual`); pixelmatch pulls pngjs 6 for its CLI | test | | tools module (`tools/go.mod`): staticcheck, golangci-lint v2.15.0, govulncheck, gremlins | gates, pinned, isolated from the main module graph | dev | Deliberately not used: `prometheus/client_golang` (counters and gauges are about 150 lines; it would add ~15 modules), web frameworks/routers (Go 1.22+ `ServeMux` patterns are enough), ORMs and migration libraries (about 60 lines of migrator), config libraries, CLI frameworks. Stdlib features used: `net/http.CrossOriginProtection` (CSRF), `crypto/pbkdf2` (password hashing), `log/slog`, `embed`, `html/template` (its escaping helpers and `template.HTML` for markup a component already escaped; the pages are templ). --- ## 4. Data model and migrations SQLite file `/tasmofleet.db`. Opened with `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`, `synchronous=NORMAL`, and **one** open connection (`SetMaxOpenConns(1)`). The fleet has tens of devices, so a single writer removes `SQLITE_BUSY` handling entirely. Migrations are plain SQL files in `internal/store/migrations/NNNN_name.sql`, embedded and applied in order inside one transaction each. `PRAGMA user_version` stores the last applied number. A merged migration file is never edited. Downgrades are not supported: the store refuses to open a database whose `user_version` is newer than the binary knows, and says so. Before applying migrations, the store writes a copy of the database to `/tasmofleet.db.pre-` (only when migrations are pending; the newest 3 copies are kept). Tables (authoritative: `0001_init.sql`): | Table | Content | Notes | | ------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `devices` | inventory + detected identity + coarse health | unique MAC (if known), unique topic (if set), unique (address, port). `http_password` sealed with secretbox. `ota_excluded`, `mqtt_capability` | | `health_events` | health state transitions | written on change only, pruned (default 90 days) | | `firmware_artifacts` | cache index | unique (release, filename), sha256, family proven by image header, `safeboot` flag | | `ota_batches`, `ota_jobs`, `ota_job_events` | OTA | partial unique index: at most one non-terminal job per device. Every transition writes a row to `ota_job_events` | | `backups` | backup index | files under `/backups//` | | `audit_log` | append-only audit | pruned by `AUDIT_RETENTION` (default 1 year) | | `sessions` | web sessions | the store holds `sha256(cookie)`, never the cookie | | `local_users` | local accounts (role, disabled, must_change_password) | written by CLI and admin UI with step-up | | `settings` | small key/value (e.g. last firmware index refresh) | not for configuration, which is env/file | Timestamps are INTEGER unix milliseconds, UTC. JSON columns (`tags`, `identity_before`, `principal`, `detail`) are plain TEXT, encoded and decoded in `store`. File layout under `/data`: ``` /data/tasmofleet.db (+ -wal, -shm) /data/secret.key 32 random bytes, 0600 (device passwords at rest) /data/firmware// cache, 0644 (served to devices; contains no secrets) /data/backups//-. 0600 /data/tmp/ decoder scratch (PyInstaller extraction), cleaned on start /data/tasmofleet.env optional config file ``` All files are created with explicit modes. The process never runs `chmod -R` over the data directory (TasmoAdmin lesson A4). --- ## 5. Device protocol facts (package `tasmota`) - `GET http://:/cm?cmnd=` always answers 200 with `application/json`, chunked, `Connection: close`, one JSON object (OBS). Unknown command: `{"Command":"Unknown","Input":""}`. Known command without output: `{"Command":"Error",...}`. Empty: `{"WARNING":"Enter command cmnd="}`. Wrong credentials: 401 `{"WARNING":"Need user=&password="}` (SRC). - Credentials are sent as `Authorization: Basic`, never as `user=`/`password=` query parameters (those end up in logs and error messages, TasmoAdmin lesson B2). Every error and log line passes through a redactor. - The client allows **one request at a time per device** (ESP8266 web servers are single-threaded). Requests to the same device queue, requests to different devices run in parallel up to a global limit (default 8). - Timeout classes: `Status N` 10 s, `OtaUrl`/`Upgrade` 15 s, `/dl` 20 s, `GET /` 10 s. Responses over 1 MiB are truncated and rejected. - SSRF: `Target` can only be built from an inventory `Device`. The dialer resolves the host and refuses any IP outside `DEVICE_NETWORKS`. The check runs on the resolved address at connect time (`net.Dialer.Control`), so DNS rebinding does not bypass it. Redirects are not followed. - **`Status 0` is never sent by automated code.** It returns 12 sections at once; on ESP8266 with <20 kB heap this produced spliced, undecodable payloads in this fleet. Identity probing uses `Status 2`, `Status 4`, `Status 5`, `Status 6`, `Status 11` and plain `Status` (the Status object only: DeviceName and the resolved Topic), one at a time, ≥200 ms apart. - Minimal firmware (SRC): only `Upgrade`, `Upload`, `OtaUrl`, `SerialLog`, `Restart` exist; everything else is Unknown. The running version comes from bare `Upgrade` (`{"Upgrade":"1 or >16.0.0 to upgrade"}`). The image name and the language build come from `GET /`: footer `Tasmota 16.0.0 (release-minimal) by Theo Arends`, ``, and the heading `MINIMAL firmware
please upgrade` (`RootPage`). On ESP32 the safeboot partition shows a "Safeboot" page with the same command table. - **Minimal detection rule**: a device runs minimal (or ESP32 safeboot) only if `Status 2` answers `{"Command":"Unknown"}` **and** `GET /` shows the minimal/safeboot heading or the image tag `release-minimal`/`safeboot`. The bare `Upgrade` reply (`1 or >X to upgrade`) is identical on normal firmware (OBS, all fixtures). It only supplies the version (`tasmota.UpgradeProbe.Idle`, `fwselect.UpgradeProbe.Version`) and never proves minimal on its own. - **Identity source (#87).** The HTTP probe is the primary source. For a device on our broker (`mqtt_capability = ours`) whose identity no HTTP probe has stored (`identity_at` unset: HTTP unreachable, refused, or not yet tried) MQTT is a second source, one that never replaces the first: (1) `tele//INFO1`, published at every boot, carries `Version`; it is read with the `StatusFWR.Version` grammar (`fwselect.DetectInfo1`). INFO1 has no hardware, so on its own it fills the version only (never image or variant, and nothing once the hardware is known) and the identity is not `Known()`. (2) Once a device has been online for 30 s (HTTP gets the first go) and still lacks its hardware, tasmofleet asks `Status 2` over MQTT (`cmnd//STATUS` with payload `2`, through the configured FullTopic) and reads `stat//STATUS2` through `fwselect.Detect`, so family, hardware and the `Known()` rule are the same as over HTTP. The question follows discovery's Status 5 limits: only online devices, once per device per 10 minutes, at most one per second on the bus, one at a time per device, never `Status 0`, logged and audited (`identity.mqtt_ask`); minimal and safeboot firmware (no Status 2) are not asked, and an answer claiming such an image is refused. Only devices recorded as `ours` are read (an INFO1 seen earlier is kept until then); the online state is dropped when the broker connection is lost, so the grace starts anew after a reconnect. Only a reply within 30 s of the question is taken: anyone on the broker can publish a STATUS2. MQTT writes only the six identity fields (family, hardware, single core, version, image, variant) and only while `identity_at` is unset (`store.UpdateIdentityFromMQTT`, compare-and-set; the OTA preflight is an HTTP probe and sets it too); MQTT never sets `identity_at`, so the first HTTP probe still runs and its identity replaces the MQTT one, and a probe that finishes meanwhile always wins. OTA is unchanged: the engine probes over HTTP before it plans and `Guard` runs on that identity. A retained INFO1 may describe the last boot only; a device on a stale one is corrected by its next boot or its HTTP probe. - Minimal firmware never persists settings (SRC: `SettingsSave` is compiled out). An `OtaUrl` set while minimal runs lives in RAM only. - Command output over HTTP depends on `WebLog >= 2`. If a reply is a `WARNING` about the web log level, the client sets `WebLog 2` once, logs it (flash write!), and retries. - **Push upload (F-9, optional per-transaction delivery, ESP32 only; SRC `xdrv_01_9_webserver.ino`)**: the web UI's "Upgrade by file upload" is `GET /up` then a multipart `POST /u2?fsz=` (field `u2`, non-empty file name, first byte `0xE9` or `0x1F` for gzip, exact `Content-Length`, no chunking). The upload type is set by the `GET /up`, not by the POST: a POST without it answers "Upload Successful" and restarts the device without flashing. `Client` therefore fetches `/up` first, requires the upload form (`action='u2?fsz='`) and holds the device slot for both requests (`tasmota.Uploader`). The reply page says `Successful` or `Failed` plus a reason; a refusal (or a missing form) is certain, nothing was flashed, and the job fails at once instead of waiting for a reboot. Any other error after the body started is not certain and is probed. The image must fit Status 4 `Free` less one 4 KiB sector (Tasmota's `Update.begin` bound); an ESP32 with the safeboot layout (one app partition) reports only the rest of its running partition there and cannot take a full image while running (research: `tasmota-minimal-and-ota.md`, "File upload on an ESP32 with the safeboot layout"). The state machine is unchanged: `set_ota_url` only runs Guard, `upgrade_issued` uploads, a lost reply is probed and never resent, `OtaUrl` is never touched. The choice is "push where possible": the download stays the default for every device, and with push chosen each device that cannot take an upload (ESP8266, unknown chips, safeboot/minimal, staged plans, an image larger than the free flash or an unknown free flash) gets the download instead. The preview says per device which delivery it gets and why, the job stores the delivery it really uses (shown on the transaction page), and a job submitted as a push whose plan no longer allows one fails before touching the device instead of switching unseen. A push job sends its image once (no second attempt after a stalled or lost upload). ESP8266 stays on the download until a real-hardware test. - Device output is untrusted text. It is size-limited and parsed with tolerant JSON (control characters stripped), and HTML-escaped by templ on output. --- ## 6. Identity detection (package `fwselect`, `Detect`) Inputs: `Status 2` (`StatusFWR`), `Status 4` (`StatusMEM`), and on minimal firmware the `Upgrade` probe plus `RootPage`. ### 6.1 Chip family from `StatusFWR.Hardware` | Hardware string (prefix match, case-sensitive) | Family | Source | | ------------------------------------------------------------------------- | -------------------------- | ------------------------------- | | `ESP8266EX`, `ESP8266` | `esp8266` | OBS | | `ESP8285N08`, `ESP8285H16`, `ESP8285` | `esp8266` | OBS / SRC | | `ESP32-D0WD`, `ESP32-D0WDQ6`, `ESP32-D0WD-V3`, `ESP32-D2WD`, `ESP32-PICO` | `esp32` | OBS (`ESP32-D0WDQ6 v1.0`) / SRC | | `ESP32-S0WD`, `ESP32-SOLO` (single core) | `esp32`, `SingleCore=true` | SRC | | `ESP32-S2` | `esp32s2` | SRC | | `ESP32-S3` | `esp32s3` | SRC | | `ESP32-C2` | `esp32c2` | SRC | | `ESP32-C3` | `esp32c3` | SRC | | `ESP32-C5` | `esp32c5` | SRC | | `ESP32-C61` (check before `ESP32-C6`) | `esp32c61` | SRC | | `ESP32-C6` | `esp32c6` | SRC | | `ESP32-P4` | `esp32p4` | SRC | | anything else (e.g. `ESP32-H2`, `ESP32-S31`, empty) | `unknown` -> never updated | DEC | Longest prefix wins. The table lives in one place (`fwselect`) with a test row per entry and one per captured fixture. Unknown is final: no fallbacks, no substring guesses. `ESP32-S31` and other new chips are added only together with a fixture. ### 6.2 Image, variant and language from `StatusFWR.Version` `Version` = `(-)` (SRC: `support_command.ino` Status 2 prints version, image name and `GetCodeCores()`), e.g. `16.0.0(release-tasmota)`, `16.0.0(release-tasmota32)`, `16.0.0(release-minimal)`, dev builds `16.0.0.3(85cff52-tasmota)`, solo1 `16.0.0(release-solo1)single-core`: - `` is `..`, plus `.` for development and patch builds (`tasmota.ino`). Nothing else (no `-dev`, no two- or five-field versions). - `` is empty or `single-core` (only builds with `CORE32SOLO1`, `support_esp32.ino`; Tasmota never prints `dual-core`). `single-core` on hardware that is known and not the classic ESP32 is `ErrFamilyMismatch`. - A `Version` that does not follow this grammar does not abort `Detect` (#45): the identity keeps family, hardware, language and flash, `Version` stays empty and the variant `unknown`. It is never `Known()`, so Guard refuses every OTA (rule 1), the same contract as unknown hardware. Nothing is guessed from the string. The minimal-firmware path (Upgrade probe and main page) stays strict. `testdata/fixtures/firmware-versions.json` lists every shape with its source. `Identity.Image` keeps the raw tag. A self-compiled build reports the bare environment name (`16.0.0(tasmota32)`, no prefix): the variant stays `unknown`, so OTA never replaces a custom build without an explicit choice; the UI shows the reported name as "tasmota32 (self-compiled)". The variant is normalised: The image tag is the build's `CODE_IMAGE_STR` (SRC, arendst/Tasmota `63c3861`; every row with its source line is in `testdata/fixtures/firmware-versions.json`), not the upstream file name (#85). The chip always comes from `Hardware` (§6.1): | image tag (after the `-` prefix) | runs on (Hardware) | Variant -> file (§7.1) | | ------------------------------------------------------------------------------ | -------------------------- | ----------------------------------------------------------------------- | | `tasmota` | esp8266 | `tasmota` -> `tasmota.bin.gz` | | `minimal` | esp8266 | `minimal` | | `lite`, `sensors`, `knx`, `zbbridge`, `zigbee` | esp8266 | same -> `tasmota-.bin.gz` | | `tasmota-4M` | esp8266 | `4M` -> `tasmota-4M.bin.gz` | | `display`, `ir` (one `CODE_IMAGE_STR` for both families) | esp8266 or esp32 dual core | same -> `tasmota-.bin.gz` / `tasmota32-.bin` | | `webcam`, `bluetooth`, `nspanel`, `zbbrdgpro` | esp32 dual core | same -> `tasmota32-.bin` | | `lvgl-haspmota` (`lvgl` up to v11) | esp32 dual core | `lvgl` -> `tasmota32-lvgl.bin` | | `tasmota32` (every `FIRMWARE_TASMOTA32` build but solo1, one per chip) | esp32 dual core | `tasmota` -> `tasmota32.bin` | | | s3, c2, c3, c5, c6, c61 | `tasmota` -> `tasmota32.bin` | | | s2, p4 | `unknown`: two builds each (s2/s2cdc, p4/p4r3), the tag cannot tell | | | esp32 single core | `unknown`: no official build reports this; not provable | | | esp8266 | `ErrFamilyMismatch` | | `solo1` (with suffix `single-core`) | esp32 single core | `tasmota` + `SingleCore` -> `tasmota32solo1.bin` | | `safeboot` | any esp32 chip | `safeboot` (device sits in safeboot; treated like minimal) | | anything else, file names (`tasmota32c3`, `tasmota32-webcam`), bare tags (#52) | | `unknown` -> refuse updates until the user chooses a variant explicitly | History and limits (SRC): - The `tasmota32` tag exists since v12.5 (`tasmota_configurations_ESP32.h`). ESP32 builds up to v12.4 report `release-tasmota`, which `Detect` refuses as `ErrFamilyMismatch` on ESP32 hardware (known limitation, fails safe). - Until v14.0.0 the USB-CDC builds of C3, C6 and S3 were separate OTA files (`tasmota32c3cdc.bin`, ...); since v14.0.0 one build per chip has USB HWCDC with serial fallback (Tasmota CHANGELOG, #21212). The cdc files are not in §7.1 and never selected. - S2 and P4 identify with variant `unknown`. After an explicit variant choice, Select still has to prove the build (§7.2): P4 by the chip revision Status 2 reports in `Hardware` (`ESP32-P4 v.`): `p4` (board `esp32p4`, chip_variant `esp32p4_es`, built for revisions 0.1-1.99) or `p4r3` (ESP-IDF default 3.1-3.99, `Kconfig.hw_support`); any other revision is refused. S2 is always refused (`ErrUnknownHardware`): nothing the device reports tells `s2` from `s2cdc`. An explicit build choice is a follow-up issue. With unknown hardware the tag is not checked (the identity is never `Known()`). If the tag's chips do not include the Hardware family (e.g. `webcam` on an ESP32-C3), `Detect` returns `ErrFamilyMismatch`. This must never happen, so if it does, stop. Language builds (`tasmota-DE.bin.gz`, `tasmota32-DE.bin`) keep the image tag `tasmota` / `tasmota32`. The language is read from `` on `GET /` (`de` -> `DE`, `en` -> empty), and Detect takes it from `RootInfo`. ### 6.3 Flash from `Status 4` `FlashSize`, `ProgramFlashSize`, `Free` (KB). The fleet's 1 MB devices report `FlashSize 1024` (fixtures `livingroom-readinglamp`, `bedroom-cabinet`). Values are stored in `Identity` and refreshed on every preflight. When firmware is minimal, `Status 4` is not available and the last stored values are kept. ### 6.4 MQTT capability (`Device.MQTT`) From `Status 6` (`StatusMQT`): missing, `MqttHost` empty, or SetOption3 off -> `none`. Otherwise `other`, until a message on our broker matches the device's topic -> `ours`. Health uses MQTT only for `ours` devices. A device is never marked degraded because MQTT is missing (TasmoAdmin `mqtt_expected` lesson). --- ## 7. Firmware selection and the safety guard (package `fwselect`) ### 7.1 Artifact naming (upstream, verified on release v16.0.0) | Family | Directory on ota.tasmota.com | File | | ------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | esp8266 | `/tasmota/release/` | `tasmota.bin.gz`, `tasmota-.bin.gz` (`minimal`, `lite`, `sensors`, `ir`, `knx`, `display`, `zbbridge`, `zigbee`, `4M`), `tasmota-.bin.gz` | | esp32 | `/tasmota32/release/` | `tasmota32.bin`, `tasmota32-.bin`, `tasmota32-.bin`, `tasmota32solo1.bin` | | esp32 chips | `/tasmota32/release/` | `tasmota32.bin` (`s2`, `s2cdc`, `s3`, `c2`, `c3`, `c5`, `c6`, `c61`, `p4`, `p4r3`) | | never an OTA target | | `*.factory.bin` (serial flashing), `tasmota32*-safeboot.bin`, `*.map.gz` | ESP8266 uses `.bin.gz` (gzip OTA since Tasmota 8.2). ESP32 uses the plain `.bin` app image. GitHub release assets (`arendst/Tasmota`, tag `v16.0.0`) have the same file names. ### 7.2 Select `Select(identity, choice)` returns the file name. It keeps the detected variant, language and chip build unless the user explicitly chose otherwise (`Choice.AllowVariantChange`, set only from a UI confirmation or a CLI flag). For esp8266 it always also names `tasmota-minimal.bin.gz` of the same release. Language and variant cannot be combined upstream (there is no `tasmota-sensors-DE`). Asking for that returns `ErrNoSuchVariant`. The chip build of the newer chips comes from `Hardware` alone: the one build of S3, C2, C3, C5, C6 and C61; for P4 the build whose revision range contains the chip revision (§6.2); ESP32-S2 and every other case return `ErrUnknownHardware`. This also covers the minimal/safeboot auto-default ("tasmota" chosen for the device). ### 7.3 Decide (update policy) Per batch: `only-newer` (default), `reinstall`, `allow-downgrade`. Versions compare numerically per dotted field. A device on minimal/safeboot always decides "upgrade". One function, table-tested (TasmoAdmin lesson A5). ### 7.4 InspectImage (verified against `testdata/fixtures/firmware-headers/`) - Byte 0 must be `0xE9`, otherwise `ErrBadImage`. `.bin.gz` is gunzipped first; byte 0-1 of the file must be the gzip magic `1f 8b`. - ESP32 app image: byte 8 = `0xEE` and `chip_id` = uint16 LE at offset 12. Mapping: `0x0000` esp32, `0x0002` s2, `0x0005` c3, `0x0009` s3, `0x000C` c2, `0x000D` c6, `0x0012` p4, `0x0014` c61, `0x0017` c5. Unknown chip_id -> `ErrBadImage`. - Otherwise ESP8266 (no extended header; byte 8 is a segment load address byte). - solo1 and dual-core ESP32 share chip_id 0. The file name and `SingleCore` decide. The firmware cache runs InspectImage on every download and rejects files whose header family disagrees with their name. ### 7.5 Guard (hard, runs immediately before `OtaUrl` and again before `Upgrade`) Refuses, with a typed error, unless **all** hold: 1. `identity.Known()` and family != unknown -> else `ErrUnknownHardware`. 2. `artifact.Family == identity.Family` (header-proven) -> else `ErrFamilyMismatch`. 3. The file-name family (`tasmota32c3...` -> esp32c3, `tasmota-*`/`tasmota.bin.gz` -> esp8266) equals both, and `path.Base(otaURL) == artifact.Filename`, and the URL is the firmware server's URL for that artifact -> else `ErrFilenameMismatch`. 4. Not `artifact.Safeboot`, not `*.factory.bin` -> else `ErrSafeboot`. 5. esp8266 artifacts end in `.bin.gz`, esp32 artifacts end in `.bin` -> else `ErrFilenameMismatch`. 6. Single-core ESP32 gets only `tasmota32solo1*`, dual-core never gets solo1 -> `ErrFamilyMismatch`. 7. Variant/language equal the detected ones, or `AllowVariantChange` is set -> else `ErrVariantChange`. Exception: the minimal stage artifact for esp8266. 8. URL length ≤ 100 characters (Tasmota settings text pool) -> else `ErrFilenameMismatch`. The engine also checks that the `Upgrade 1` reply `Version X from ` echoes exactly the URL it set. If not (another tool changed OtaUrl in between), the job fails. Guard and Plan run under mutation testing with a required efficacy ≥ 90 % (§21). ### 7.6 Plan (per-family update policy) | Family | Plan | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | esp32\* | **direct**. If the device uses a single OTA partition with safeboot, Tasmota itself goes through safeboot and continues with the stored OtaUrl (SRC). The engine tolerates seeing `safeboot` in between | | esp8266 | **staged** if `target .bin.gz size > Free*1024 - 16 KiB` (margin), or if Free is unknown and `FlashSizeKB <= 1024`; otherwise **direct** | Never decided by model name. Fleet check (OBS, fixtures): every ESP8266 in the fleet, the ESP8285N08 lamps and the ESP8266EX plugs alike, reports `FlashSize 1024`, `Free 352` (360 448 bytes). `tasmota.bin.gz` 16.0.0 is 469 152 bytes and `tasmota-sensors.bin.gz` is 512 993, so they are **staged**. `tasmota-minimal.bin.gz` (263 670) fits. The compressed image is what lands in flash (the bootloader unpacks it), so the gz size is the one to compare. For ESP32, `Free` (697 KB on the QuinLED) is not the OTA partition size and is ignored; ESP32 is always direct. --- ## 8. Firmware sources, cache and server (package `firmware`) ### 8.1 Sources | Name | Kind | Index | Download hosts (allowlist) | | ----------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `github` | `github-releases` | `GET https://api.github.com/repos/arendst/Tasmota/releases?per_page=10` (JSON, machine-readable) | `api.github.com`, `github.com`, `objects.githubusercontent.com`, `release-assets.githubusercontent.com` | | `ota.tasmota.com` | `ota-tasmota` | HTML listing of `/tasmota/release/` and `/tasmota32/release/` (only `href` file names are parsed; version from the release notes heading) | `ota.tasmota.com` (http allowed) | `FIRMWARE_SOURCES` orders them. GitHub is the primary index because it is JSON. If both sources are enabled, every download is fetched from both and the SHA-256 must match (cheap cross-check against a tampered mirror). A mismatch rejects the file and raises an error in the UI. The index is refreshed at start (async, backoff 30 s..30 min until the first success) and then every `FIRMWARE_REFRESH_INTERVAL` (default 6 h); a failed refresh keeps the previous index (gauge `tasmofleet_firmware_index_age_seconds`). It is never fetched on page render (TasmoAdmin lesson B6), only by an explicit refresh or a background refresh (default 6 h). ### 8.2 Cache `/data/firmware//`, immutable, indexed in `firmware_artifacts` with size, SHA-256, header-proven family, variant, language and safeboot flag. Downloads go to a temp file, then fsync and rename. Size limit 8 MiB. A redirect hop outside the allowlist aborts. `Prune(keep)` deletes releases beyond the newest `FIRMWARE_KEEP_RELEASES` (default 3) unless an active job references them. Uploads never wipe the cache (TasmoAdmin lesson B6). ### 8.2a Firmware page: cache management, prefetch, changelog (M2) - **Release notes.** The GitHub index already carries each release's `body`; the cache keeps it (cut to 32 KiB per release, at most the 10 indexed releases) next to the index and the release page URL. The `ota.tasmota.com` source has none. Notes are rendered by `firmware.ParseNotes`, a minimal Markdown subset (headings, bullet/ordered lists, fenced code, quotes, `**bold**`, `` `code` ``, `[text](url)`, bare URLs) that produces plain data, never HTML. templ escapes every string; there is no `template.HTML` on the path. Raw HTML in a body is shown as text. A link survives only if `firmware.NoteLink` accepts it (absolute `http`/`https`, public DNS host, standard port, no user info, no whitespace or control characters) and its host must be on an allow-list (project and GitHub hosts, configurable); it is rendered with `rel="noopener noreferrer nofollow"` and `target="_blank"`. Images are never loaded (shown as links). **No new dependency:** goldmark plus bluemonday would be a large parsing and sanitising surface for four constructs, and a sanitiser allow-list is the weaker design than "the parser cannot emit markup". - **Delete.** `firmware.Manager.Delete` / `DeleteUnused`: admin only, a confirmation page, audited (`firmware.delete`, also the refusals). Refused while an active or queued job holds the artifact as target or tasmota-minimal ("in use by job N"); refused if the lookup fails. The same per-file lock as `Ensure` serialises a delete against a download of that file. Residual: the window is the whole planning step (it can include a download); the job then fails on the artifact id mismatch, never flashes the wrong file. For direct ESP8266 plans the job row records the minimal image too. - **Fleet needs.** `firmware.FleetNeeds(release, devices)`: per device `fwselect.Select` (keeping variant and language; a device on minimal/safeboot gets the standard build) plus `tasmota-minimal.bin.gz` for every ESP8266, excluded devices and devices with unknown hardware need nothing. This is the single computation behind the per-release coverage ("cached N of M needed files"), the "devices that would use this file" column, and the prefetch file list. It makes no safety decision; the OTA engine still runs the Guard. - **Prefetch.** `POST /firmware/prefetch` (admin) computes the file list on the server from the inventory (the form only names a release that is in the index), starts `firmware.Prefetcher` (one run at a time, sequential `Cache.Ensure`, so size limit, cross-check and image-header check are unchanged, 30 minute timeout, audited `firmware.prefetch`) and redirects to a progress page fed by an SSE stream like the discovery scan. - **Update indicator.** `firmware.UpdateAvailable(latest, identity)` is the one definition of "update available" (known version older than the newest stable release) for the firmware page and the device list; `firmware.Latest` is the "recommended" release. ### 8.3 Device-facing firmware server Its own listener (`FIRMWARE_LISTEN`, default `:80`). It serves only `GET`/`HEAD` `/fw//` for indexed artifacts, with `Content-Length` (ESP8266 checks free space from it) and `Content-Type: application/octet-stream`. There is no directory listing, no other routes, and no authentication (devices cannot authenticate). Every request is logged with the device IP and published as an event. The engine uses "device X started downloading" as a progress hint. All files of one release share a directory, so Tasmota's own fallback (it rewrites the OtaUrl file name to `tasmota-minimal.bin.gz` in the same directory, SRC) also resolves. M2 adds `/restore/` (§12.4). `FIRMWARE_BASE_URL` is the URL devices use (`http://`). It must be plain `http://` (validated). There is no auto-detection: in ipvlan/bridge setups the right address is only known to the operator. ### 8.4 Variant list for the UI The update dialog offers, per device, only files of the device's family that exist in the chosen release (from the index). The default is the detected variant. ### 8.5 Channels `stable` (releases) in M1. `development` builds (ota.tasmota.com root) in M2, behind a config switch. ### 8.6 Custom firmware upload (M2) Admin uploads `.bin`/`.bin.gz` (`POST /firmware/custom`, multipart, CSRF token in the header, streamed to a temp file) with a label, a dotted numeric version (the release directory) and a build name (default `tasmota`). Container (gzip magic) and chip family come from the bytes via InspectImage; the file name and MIME type are display data. Limits on the uncompressed image: 4 MiB ESP8266, 8 MiB ESP32. Refused: unknown or mismatching chip, safeboot/factory/bootloader names, a partition table at 0x8000 (full-flash image), corrupt or tiny files, the same bytes twice. The version may equal an upstream release (self-built images report the version they were built from): an upload is identified by `Artifact.Custom`, the first 10 hex digits of its SHA-256, and fetched at `/c//fw//` (`Request.Custom`/`Job.TargetCustom` carry the key; the engine resolves such jobs with `EnsureCustom`, never `Ensure`). The Guard is unchanged. The image is stored as `custom///` (ESP8266 gzip, ESP32 raw, whatever was uploaded) with `Source=custom` and provenance in `firmware_custom` (the release column of an upload is stored as `~`, so the old UNIQUE(release, filename) holds) (label, uploader, time, original name). An ESP8266 custom release gets `tasmota-minimal.bin.gz` as a verified copy of the newest upstream minimal (`Source=custom-minimal`) on first use, so the two-step update works. Uploads are exempt from Prune and "delete unused"; Delete refuses while a job needs them. The update page offers them as `custom: