• v1.1.0 1023e945bd

    v1.1.0
    All checks were successful
    CI / Alembic migration check (push) Successful in 36s
    CI / Dockerfile lint (push) Successful in 8s
    CI / Pre-commit hooks (push) Successful in 1m16s
    CI / Python lint & type-check (push) Successful in 1m35s
    CI / Frontend lint, test & build (push) Successful in 1m20s
    Release / Publish image & release (push) Successful in 1m41s
    CI / Python tests (push) Successful in 4m54s
    CI / Docker build, health smoke & E2E (push) Successful in 2m54s
    Stable

    claude-bot released this 2026-09-06 00:56:56 +00:00 | 4 commits to main since this release

    Added

    • Demo mode (#63) — DEMO_MODE=true swaps the HTTP controller for an
      in-process simulator, so the whole workflow runs with no hardware: the year
      generates, the review queue fills, schemes preview as an animated virtual
      strip (#14), and pushes and preset sync succeed.

      An empty database also gets three segments and a handful of approved schemes
      across the year, so the calendar has colour in it instead of being a blank
      grid. It seeds only an empty install — existing segments and approvals are
      never touched, because this is a flag someone can set on a database that
      already has their work in it. And it deliberately leaves the rest as
      candidates: the review queue with things in it is the feature being
      demonstrated.

      The simulator subclasses the real controller rather than reimplementing
      it, so every consumer works unchanged and mypy checks the substitution is
      total. That paid for itself immediately: a test comparing the two surfaces
      found get_info un-overridden, falling through to an HTTP call against host
      "demo" — a DNS error that would have read like a network problem.

      It serves the effect catalogue captured from a real 16.0.0 device rather than
      invented names, because the browser simulator maps effect IDs to animations
      and a fabricated catalogue would make every demo preview show the wrong
      effect. That capture moved from tests/fixtures/ to app/data/ so there is
      one copy, shipped, rather than a test fixture duplicated into the image.

      Two things it is not: nothing on your network lights up while it is on (a
      startup warning says so), and it never fails, so it exercises none of the
      retry, fallback or unreachable-controller paths. README screenshots and an
      optional public instance are split to #136 — I can generate a screenshot but
      I cannot look at it and tell whether it is a good one.

    • Memorial events (#80) — a Memorial button on the calendar toolbar takes
      a name, a date, optional notes and an "remember every year" toggle, and
      produces a candidate scheme to review.

      Most of this already worked: a memorial is a user-defined event, and that
      path has been complete since v1. The work was the gaps around it.

      The AI prompt was holiday-shaped. It opened "generate a JSON lighting
      scheme for a holiday or observance event" with festive few-shot examples —
      asked for a memorial it would reliably return something bright and fast for
      someone's funeral. The system prompt now branches, and the examples are
      replaced rather than appended: showing both invites the model to average two
      incompatible briefs. Filtering a celebratory scheme afterwards would give a
      muddy version of the wrong idea rather than the right one.

      A memorial tag template in schemes/builtin.yaml — soft amber candlelight
      on a slow breathe, low brightness, an eight-second transition, because a
      memorial that snaps on at sunset is the wrong gesture. AI_PROVIDER is
      optional and capped, so without this a memorial with no provider fell through
      to solid white.

      Annual recurrence is opt-in, and the Jan 1 job materialises the next
      occurrence reusing the same event_key — which is what makes the anniversary
      light exactly as it did last year with no AI call and nothing to review. A 29
      February anniversary is skipped in non-leap years rather than moved: it
      genuinely only occurs every four years, and picking a neighbouring date on
      someone's behalf is not the code's decision.

      Priority is on the form, not hidden behind its default. User events sit at
      100 and outrank everything, so a memorial on 23 December replaces Christmas
      Eve — the intended behaviour, but a surprising enough one that it should be
      visible when it is chosen rather than discovered in December.

      Also corrected here: builtin.yaml's header comment claimed 65=Colorful and
      66=Traffic. Those are Palette and Fire 2012 (#132) — the same class of error,
      in the file most likely to be read by someone choosing an effect.

    • The scheme preview now approximates the right effects (#14) — selecting a
      scheme animates a virtual LED strip, which it already did; what changed is
      that the animation is now keyed to what each WLED effect actually is.

      The mapping was wrong: it keyed on IDs 57, 65, 66 and 9 believing them to be a
      comet, a wave, traffic lanes and a chase. They are Lightning, Palette, Fire
      2012 and Rainbow. Effects are now grouped into twelve families — solid,
      breathe, wipe, chase, twinkle, sparkle, fire, gradient, rainbow, comet, strobe
      and aurora — built from the names a real 16.0.0 controller reports, with
      anything unrecognised animating as a generic shimmer rather than pretending to
      be solid.

      It approximates; it does not emulate. WLED ships 220 effects, many 2D or
      audio-reactive, and reproducing them would be a second implementation of
      someone else's firmware that drifted the moment they changed anything. The
      goal is that a reviewer approving a year of schemes can tell them apart.

      The renderer moved out of the component's draw loop into a pure function, and
      gained the tests it never had. Two real bugs surfaced immediately: the wipe
      sampled the palette between slots, so a two-colour scheme showed the same
      purple on both sides of the boundary and the wipe was invisible; and every
      twinkling LED shared one fade, making it a breathe with holes. Works with no
      WLED_HOST configured, as it always has — nothing here touches the
      controller.

    • A live remote-control page (#24) at /remote — colour swatches, a custom
      picker, brightness, effect, speed and intensity, all pushing immediately, plus
      a one-tap Back to schedule. Laid out for a phone first, because the
      plausible use is standing in the garden looking at the tree.

      It drives the existing quick-push endpoint rather than inventing a second push
      path, which is what makes it safe to hand to someone at a party: quick push
      saves the controller's state, applies the new one, and hands the night back at
      the next scheduler event, so nothing the page does can leave the lights
      somewhere the schedule will not recover from. Admin-gated, matching the
      endpoint.

      Quick push gained brightness_pct, effect_id, effect_speed and
      effect_intensity, meaningful only alongside a colour — a scheme carries its
      own, and overriding those would mean two sources of truth for one push. All
      four default to the previous behaviour, so the toolbar's existing quick push
      is byte-identical.

      Also fixed here: POST /wled/push ("Push now") built its scheme id directly
      instead of going through the resolver #74 added, so on an interpolated event
      the button sent the phase anchor while the scheduler sent the blended day.
      Two answers to one question is the drift #93 fixed once already, and it now
      has a test that fails with the old lookup restored.

    • Astronomical events (#20) — seven meteor showers and eleven named full
      moons now resolve as calendar events, each with its own built-in scheme, plus
      an optional aurora layer.

      Full moons are computed from astral, which is already a dependency, so
      unlike the solstices they add nothing to the lookup-table horizon (#61) and
      keep working indefinitely. Meteor showers use fixed multi-day spans rather
      than a table: Earth crosses the same debris stream on the same orbital date
      each year, so the peak moves by at most a day, and the "peak" is a few hours
      inside several active nights — a one-day event would usually miss it.

      Schemes are explicit per event rather than tag-inherited, per the convention
      test_bundled_events_have_explicit_theme_proposals enforces, and the colours
      differ by the character of each: near-black skies with sparkle for the
      showers, and the Harvest Moon in orange because the low autumn moon really
      does look that way.

      Aurora (AURORA_ENABLED, off by default) polls NOAA's planetary K-index
      and shows an aurora scheme when a storm crosses AURORA_MIN_KP, restoring the
      scheduled scheme when it subsides. It is edge-triggered, so a continuing storm
      does not restart the effect every poll. No visibility model: whether an aurora
      is actually visible depends on geomagnetic latitude as well as Kp, and that
      mapping is a rule of thumb rather than a formula — so the threshold is the
      knob instead, documented in those terms.

      Eclipses are split to #126. They need an ephemeris and a per-observer
      visibility calculation — a lunar eclipse over the Indian Ocean means nothing
      to a roofline in Texas — which is a different problem from the rest of this
      issue and a heavier dependency than the image currently carries.

    • Natural-language scheme editing (#22) — a box on the scheme editor that
      takes "make it warmer and slower" and returns an edited scheme, reusing the
      existing provider abstraction, validator, one-shot retry and daily cap.

      It proposes; it never writes. The endpoint returns a before and an after,
      and accepting one loads it into the editor's own controls — so the user saves
      it with the ordinary Save, and an AI edit goes through exactly the review a
      manual edit does. Persisting directly would have let an AI edit of an
      approved scheme reach the lights with nobody seeing it.

      A failed refinement raises rather than falling back. Generation falls back
      to a built-in template when the model fails, which is right: it has nothing to
      lose. Refinement starts from the user's own work, so the same fallback would
      replace what they were editing with a generic template — losing work instead
      of declining to change it. A failure is a 502 that says the scheme is
      unchanged, and it is.

      A segment the model forgets to mention keeps its current values rather than
      vanishing: "make it warmer" is not an instruction to delete a segment, and an
      omission is far likelier to be the model forgetting than the user asking.

    • Palette from an image (#17) — pick a photo in the scheme editor and its
      dominant colours fill the segment slots. Nothing is saved until you press
      Save, so the result is an ordinary editable scheme.

      Extracted in the browser, not on the server. The issue sketched a Pillow
      endpoint; this does it client-side instead, and the reasons are worth stating.
      Server-side extraction means a self-hosted box parsing arbitrary uploaded
      images with several megabytes of native code — a meaningful addition to the
      attack surface #60 spent a release shrinking. The browser already decodes
      JPEG, PNG, WebP and HEIC in a sandbox hardened far beyond anything we would
      add. So: no new backend dependency, no upload endpoint, no size limits or temp
      files to get wrong, and the photo never leaves the machine it is on.

      Median cut rather than k-means, because the issue asks for deterministic
      colour sourcing and k-means is not: it depends on how its centroids are
      seeded, so the same photo can give different palettes on two runs. The split
      point is the widest gap along the widest channel rather than the median index
      — textbook median cut returns mud for a flag or a logo, because the median
      falls inside a colour cluster and averages two of them together. Where the
      values are near-continuous, as in a photo, every gap is equal and the
      tie-break restores median cut's balance exactly.

    • Controller auto-discovery and segment import (#50) — Discover
      controllers
      in Settings scans for WLED's _wled._tcp mDNS advertisement and
      lists what answers, with each device's name, firmware and LED count. Picking
      one fills in the host and port; you still press Save, because the address goes
      to the same runtime setting WLED_HOST seeds.

      mDNS is multicast and does not cross a Docker bridge network, so on a
      default docker compose install discovery finds nothing however healthy the
      controller is. docs/setup.md covers the two ways round it (network_mode: host, or an Avahi reflector), and an empty scan now says which case applies
      rather than reporting "no controllers found" — that would be the wrong
      diagnosis and sends people to look at their lights. Typing the address in by
      hand works identically and remains fully supported.

      Advertised addresses are checked before they are followed. mDNS is
      unauthenticated, so anything on the network segment can claim to be WLED and
      point anywhere; since discovery then fetches /json/info from what it is
      told, an advert naming a public host would turn an admin's scan into an
      outbound request of the advertiser's choosing. Only private, loopback and
      link-local addresses are accepted.

      Import from controller, on the segments card, reads /json/state and
      proposes one Iris segment per WLED segment. It shows what it would add or
      change and applies nothing until accepted: segment identity is what every
      scheme is written against, so an import can mark approved schemes stale.
      Segments are matched by WLED index rather than name, so a segment you renamed
      to "Roofline" stays that way on re-import; and a segment the controller stops
      reporting is kept and listed rather than deleted, because it may simply have
      been reconfigured and removing it would take its schemes' segment rows too.

    • Progressive HSV phase interpolation (#74, spec §4.2) — opt in per event
      with phase_config.interpolate and the colours drift across the run instead
      of stepping between three fixed looks. A 26-day Christmas fades from its
      opening colours through the mid ones to the closing ones, a day at a time.

      Blending is in HSV, with hue taking the shorter arc of the colour wheel: red
      to magenta is 60° backwards, and a linear blend takes the 300° route forwards
      and spends a fortnight passing through green. effect_id and palette do not
      interpolate at all — effect 26 is not halfway between 12 and 40, it is an
      unrelated animation — so they step at the midpoint instead.

      The review unit stays the phase. Twenty-six independently approvable rows
      would make the review queue unusable, so the four phase schemes remain the
      anchors a human sees and approves, and the per-day rows are derived from them,
      rebuilt whenever an anchor changes and excluded from the review list, preset
      sync and config export. A derived day is approved only when every anchor it
      blends is approved: falling back to the plain anchor colours would light the
      tree in something the user never signed off on because a different scheme
      was approved.

      The side panel now previews the day's actual colours rather than the
      anchor's, via a new GET /schemes/for-date/{date} that resolves through the
      same helper the scheduler uses — so the preview and the push cannot disagree,
      which is the failure #93 fixed once already.

    • Morning lighting window (#52) — two optional transitions the v1 envelope
      could not express: OVERNIGHT_OFF_* turns the lights off in the small hours,
      and MORNING_ON_* brings them back before sunrise, either at a fixed time or
      relative to it. Both off by default and configurable in Settings.

      Later transitions fall on the next calendar day, which is exactly the
      attribution #106 got wrong for the off. Rather than special-casing midnight,
      each transition is "the first occurrence of this clock time after the previous
      one", so the crossing falls out with no branch to get wrong.

      A transition that would land at or after the off is dropped, not
      scheduled
      — a morning-on at 05:30 against a 05:00 winter sunrise would
      otherwise switch the lights on after the off job had turned them off and leave
      them burning all day. Disabling the window also unregisters the job rather
      than merely not rescheduling it: the jobstore is persistent, so a stale
      morning-on would still be sitting there waiting to fire.

      The morning push resolves against the evening's date. At 05:30 on 26
      December the lights are still showing Christmas night; resolving against today
      would swap in Boxing Day partway through.

    • Prometheus metrics (#15) — GET /metrics, off unless METRICS_ENABLED.
      Counters for scheduler job outcomes and WLED push attempts, a histogram of
      job durations, and gauges for controller reachability, firmware support, AI
      usage against the cap, and the last successful run of each job. Scrape
      config, the full metric list and three alert rules are in
      docs/setup.md.

      The last-success gauge is the one that matters: Iris is set-and-forget, so
      the failure worth paging on is the nightly push that never happened, and that
      is an absence — no log line is emitted for a job that did not run.

      Instrumentation sits at the two chokepoints every event already passes
      through (log_run for jobs, _try_push for pushes) rather than at each call
      site, so the counters cannot drift from what actually happened. State gauges
      are read from the controller status and schedule_log at scrape time rather
      than mirrored into variables, so they cannot disagree with what the UI shows.
      A test writes N schedule-log rows and asserts the counter moved by exactly N.

      The endpoint is unauthenticated when enabled, as every exporter is —
      Prometheus cannot complete an OIDC flow, the same constraint the calendar
      feed hit in #16. Unlike a feed it exposes counts rather than data, and
      nothing is labelled with a host, path or credential.

    • Off-site backup targets (#18) — the existing timestamped SQLite snapshots
      can now be mirrored to a mounted directory (BACKUP_REMOTE_PATH) or any
      S3-compatible endpoint (BACKUP_S3_*), with the same retention applied
      remotely. Off by default; an unconfigured install behaves exactly as before.
      Restore is documented in docs/troubleshooting.md.

      Credentials are environment-only and never enter the settings table, so they
      cannot reach the config bundle from #23 — a file whose whole purpose is to be
      committed to a repository.

      No boto3: S3 here is three operations, and boto3 with botocore is ~50MB in an
      image deliberately slimmed in #60, so the SigV4 signature is computed against
      httpx directly. That trade is acceptable specifically because SigV4 is request
      authentication rather than encryption — a mistake produces a 403 and a
      loudly broken feature, not a silently insecure one. defusedxml is added
      (~30KB) for the listing response, since that body is remote input and the
      stdlib XML parser expands entities.

      An unreachable target never fails a backup: the local snapshot is already
      written and is what stands between the user and data loss, so a dead NAS is
      logged and the bulk operation that triggered it still completes.

    • Declarative full-config export / import (#23) — GET /export/config
      produces a versioned YAML bundle of settings, segments, user events and
      curated schemes; POST /import/config applies one, taking a backup first and
      running the whole thing in a single transaction so a bad file cannot
      half-migrate an instance.

      Deliberately excluded: builtin events (regenerated per year, so exporting
      them would add rows that differ between instances by nothing that matters and
      conflict on import), webhooks and the calendar feed token (credentials, and a
      file whose purpose is to be committed to a repository is the wrong home for
      those), and the logs (history, not configuration). The feed token is excluded
      structurally — settings serialise through RuntimeSettings, which has no
      such field — rather than by a filter someone could forget to update.

      Determinism is the point of the format: every collection is sorted by a
      stable key and YAML is emitted with sorted keys, so an unchanged instance
      re-exports byte-identically and a real change produces a small diff. Segments
      sort by name rather than sort_order, since sort_order is itself editable
      and sorting by it would reshuffle the file whenever someone reordered the UI
      list.

      Schemes are exported when approved or promoted to source: user#99
      promotes on edit, so an edited-but-unapproved scheme is real work and
      dropping it would lose exactly what the bundle exists to preserve. Import is
      a merge, not a replacement, so restoring one host's bundle onto another does
      not delete the second host's own events.

    • iCal import and calendar subscriptions (#51) — the other direction from
      #16: upload a .ics file or subscribe to a URL, and birthdays and
      anniversaries become events that drive the lights. Subscriptions refresh with
      the weekly candidate job, so an event added to a personal calendar this week
      gets a candidate scheme on the same run.

      De-duplication needs no new column: event ids are already
      {event_key}_{year}, so deriving event_key deterministically from the
      VEVENT UID makes a re-import collide on the primary key by construction —
      which is exactly the "de-dup by UID" the issue asks for, with no second
      uniqueness rule to keep in step with the first.

      A subscription is authoritative — refreshing overwrites name and dates,
      because the remote calendar is where those events are edited. A one-off
      upload never overwrites, since nothing will ever refresh it and clobbering a
      since-edited event would be a silent loss with no counterpart benefit.
      Neither path will touch an event whose source is not ical.

      icalendar moves from a dev-only to a runtime dependency; #16 needed it only
      to validate the exported feed in tests.

    • iCal calendar feed (#16) — GET /api/v1/export/calendar.ics publishes the
      resolved lighting calendar as a subscribable feed, so a phone can answer "what
      will the lights do on the 20th" without opening Iris. Days are resolved
      individually — priority, pins and shadowing can change which event wins from
      one day to the next — then coalesced, so consecutive days sharing an event and
      phase become one entry rather than a wall of single-day rows. Days whose
      scheme is not approved are included and flagged rather than hidden; a day that
      will run nothing is worth seeing.

      "Subscribable in a standard calendar client" and "access-controlled" pull
      against each other, since Google and Apple Calendar poll a URL and cannot do
      OIDC. Resolved the way Nextcloud and Radicale do it: an install-wide feed
      token in the URL, revealed on request from Settings and regenerable, kept out
      of the ordinary settings payload because it is a bearer credential.
      Regenerating revokes every subscription at once.

      Validity is checked by parsing the output back with a real iCal library rather
      than string-matching our own format — covering the escaping, the 75-octet
      line folding that must not split a multi-byte character, and the exclusive
      DTEND that is the classic off-by-one in hand-written iCal.

    • Gradual brightness ramp at the on/off transitions (#21) —
      RAMP_ON_MINUTES / RAMP_OFF_MINUTES, also in Settings, both 0 (the
      previous instant behaviour) by default. The fade-in starts at the on time
      and reaches the scheme's brightness after the configured window; the fade-out
      ends at the off time, so it begins early and the lights are dark on
      schedule. The scheme's colours and effect still land immediately — only the
      level moves.

      The issue proposed either scheduler interval pushes or offloading the fade to
      the device. Measuring the controller ruled the second out and produced a
      better third option: WLED's transition is stored as a uint16 of
      milliseconds
      , so past 65535 ms it wraps modulo 65536 rather than clamping.
      On a 16.0.0 device, 656 (65.6 s) gives an instant snap and 1200 (nominally
      two minutes) gives a 54.4 s fade. A multi-minute device-side fade is not
      merely unavailable — asking for one silently produces a short, arbitrary one.

      What the device does do well is the fade itself: sampling info.leds.pwr
      across a 30 s transition traces a near-perfect line, ~95 mA per 2 s, finishing
      exactly on time. So a ramp is a chain of scheduler steps each carrying a
      device transition equal to the step interval
      — the scheduler sets a coarse
      target twice a minute and the device fades smoothly between them. Continuous
      rather than stepped, without one push per brightness level.

      The late-night dim cancels an in-flight ramp rather than racing it, and when
      an on transition falls inside the late-night window the ramp ceiling is the
      dimmed level, so a ramp cannot undo a dim that already happened.

    • Container hardening (#60) — beyond the non-root user above, the root
      filesystem is now mounted read_only: true with a 64MB tmpfs for /tmp, all
      Linux capabilities are dropped, and no-new-privileges is set. Everything
      Iris writes was already confined to /app/data: the SQLite database and its
      WAL, the APScheduler jobstore (the same database), backups, and the
      Calendarific response cache.

      CI runs the whole Playwright E2E suite against a --read-only,
      capability-dropped container and asserts the uid is not 0, because asserting
      that the compose file says read_only would prove nothing — a runtime write
      path that only appears under load is exactly what a config-only check misses.

    • Base images pinned by digest (#60), with Renovate's pinDigests keeping
      them current and digest re-pins auto-merging. A floating tag silently
      re-points, so rebuilding an old commit did not reproduce its image. Un-managed
      digest pins would be worse than the tag they replace — they go stale and stop
      receiving security rebuilds — hence pairing the pin with the automation in the
      same change.

    • hadolint gates CI; trivy scans the published image weekly (#60). The
      Dockerfile linter gates because it reads one file in the repo and cannot
      spontaneously redden an unrelated PR; the vulnerability scanner reports from
      audit.yml alongside the dependency audits, for the reason #81 established.

    • WLED firmware compatibility statement and translator fixtures (#62) — the
      supported range is now declared in the README and
      docs/wled-compatibility.md: 16.0.0 verified on
      hardware, 0.14.x the minimum. Recorded API responses for each version live in
      backend/tests/fixtures/wled/, and the translator, info parser and effect-id
      bounds run against all of them, so drift between WLED releases fails a test
      rather than a roofline in December. The 16.0.0 fixtures are captured from the
      dev controller; the two older sets are derived from WLED's published API
      reference and labelled as such, since no 0.14/0.15 hardware was available.
      Firmware below the minimum — or a host that is not a WLED device at all — now
      produces one clear warning at startup, a firmware_supported flag and
      explanation on GET /wled/status, and the same explanation appended to any
      failing push. Iris still drives an old controller: the fields it writes have
      been stable far longer than the supported range, so a hard gate would strand
      working setups over a problem that may not exist.

      Note WLED released 16.0.0 directly after 0.15.x rather than 0.16.0, so
      versions are parsed into component tuples and compared as such — the obvious
      shortcut of reading the second component as the version yields 0 on 16.0.0
      and would reject the newest firmware as ancient.

    • GET /api/v1/wled/effects (#62) — proxies the controller's /json/eff.
      The frontend has called this endpoint since the effect picker was built, but
      it was never implemented; useWledEffects fails silently by design, so its
      absence was indistinguishable from an unreachable controller and the picker
      always fell back to its built-in list of 113 effects against a controller
      reporting 220. Effect ids are positional and get reassigned between releases,
      so the live list is the only authoritative name→id mapping.

    • Recurrence horizon guard (#61) — six recurrences are table lookups
      (Diwali, Lunar New Year, the four solstices/equinoxes) covering 2020–2035.
      Past that they do not error: the loader skips them and they silently stop
      appearing on the calendar, so from 2036 those events would just be gone with
      nothing to say why. Startup now emits one warning naming the affected events
      and the exhausted tables instead of a scattered per-event line, a new
      GET /api/v1/recurrence-horizon reports the same, and the app shell shows a
      header banner while any loaded year is past the horizon. table_horizon() is
      derived from the data — the minimum across tables, since one running out is
      enough. docs/troubleshooting.md documents how to extend them, including why
      they cannot currently be computed: holidays stops at 2035 itself and
      astral has no solstice function.

    Changed

    • docs/setup.md §7 and docker-compose.registry.yml are now labelled as the
      dev-host and self-hoster path
      (#104), not the production runbook. This repo
      builds and publishes an image; the Ansible/IaC repo deploys it, and the
      registry is the boundary between them. Reading §7 as "how Iris is deployed"
      was an easy and wrong inference.

      The open question in that issue — whether pulling needs authentication — is
      answered by checking the live registry rather than assuming: it does not.
      An anonymous client is issued a token that grants pull on this repository,
      and the manifest fetch returns 200. So a deployment needs no pull secret, and
      docker login is optional rather than the required step §7 presented it as.
      That holds only while the repository is public, which is now written down next
      to the finding.

    • ⚠️ The container now runs as a non-root user (uid 1000) (#60) — this needs
      one manual step when upgrading. ./data is a host bind mount, so its
      ownership comes from the host, and the previous root container is what created
      it. Before the first start after upgrading:

      sudo chown -R 1000:1000 ./data
      

      Nothing in the database changes; only the file owner moves. If chowning is not
      an option, set IRIS_UID / IRIS_GID in .env to the directory's existing
      owner and the container runs as that instead. Startup checks writability
      before running migrations and exits with a message naming the uid it needs,
      so a missed chown is an obvious stop rather than a half-applied migration or
      an opaque "attempt to write a readonly database".

    Fixed

    • Two hardcoded WLED effect IDs meant something other than their comments
      said
      (#132), found by checking them against a real 16.0.0 controller's
      /json/eff rather than against the comment beside them.

      The aurora scheme (#20) pushed effect 44, labelled "Aurora". 44 is
      Tetrix — falling blocks. Aurora is 38. Env-gated and off by default, so
      nobody had seen it, but anyone who enabled AURORA_ENABLED would have got the
      wrong animation during a geomagnetic storm.

      The AI's fallback effect catalogue — used whenever no controller is reachable,
      which is the path a new user hits before wiring hardware — had five of seven
      pairs wrong
      . The model picks an effect by name and gets the number beside
      it, so it asked for Sine when it meant Twinkle, Rainbow when it meant Chase,
      and Lightning when it meant Fire 2012.

      Both are now pinned by a test against tests/fixtures/wled/16.0.0/eff.json,
      the catalogue captured verbatim from the device in #62. That fixture existed
      already; this is what makes it load-bearing. An effect ID is an opaque integer
      everywhere except where a human decides what it means, so nothing else in the
      suite could ever have caught this. schemes/builtin.yaml was audited at the
      same time and was clean.

    • An unregistered event source silently lost every priority tie (#51) —
      priority_resolver._SOURCE_RANK maps a source to a tiebreak rank and defaults
      anything unrecognised to 0, below system. Adding the new ical source
      without registering it there would have made imported events lose against
      builtins at equal priority, with nothing to show why. Registered at the same
      rank as user, and covered by a test that fails if the entry is removed.

    • transition_ms is now bounded (#21) — the API and preset import accepted
      any integer. WLED stores the transition as a uint16 of milliseconds, so
      anything past 65535 wraps on the device: a 120000 ms transition becomes a
      54.4 s fade, and 65600 ms becomes an instant snap. The scheme editor already
      capped its input at 65535, which divides to a safe value; nothing else did.

    • Preset sync now works against real firmware (#115) — it targeted
      GET/POST /json/presets, which no WLED firmware implements (a real device
      answers 501 {"error": 4}), so POST /api/v1/wled/sync-presets had never
      once succeeded outside the test suite. Presets are a file on the controller's
      flash filesystem: read with GET /presets.json, written by uploading that
      file to POST /upload, where the multipart part's filename is the
      destination path. Both verified against the 16.0.0 roofline controller,
      including that an uploaded preset file takes effect without a reboot.

      The write replaces the whole file, so sync merges into what it read and now
      reads the file back afterwards to confirm what landed — a 200 from the upload
      means the request was accepted, not that a complete file reached a flash chip.
      A truncated write is reported now instead of surfacing in December as a preset
      that does nothing.

      The alternative write path, {"psave": n} on /json/state, saves whatever is
      currently showing; syncing a year that way would light the house up once per
      preset, which is why the file upload is the only sane mechanism here.

      The file is serialised compactly. json.dumps' default separators cost ~20%
      of the payload in whitespace (2240 → 2682 bytes for four presets) on a device
      with a ~983KB filesystem that has to hold up to 366 of them. Compact output
      also matches WLED's own, so a round-trip through Iris leaves the file
      byte-identical to what the controller would have written itself.

    • Preset sync tests passed by mock aliasing (#115) — get_presets.return_value = {}
      hands back the same dict on every call, so sync mutating it made the
      read-back compare a dict against itself. The mock now models the controller's
      preset file, returning a fresh copy per read. Mocks agreeing with the code
      rather than with the device is precisely what let the /json/presets bug sit
      behind green tests for the whole v1.0.0 cycle.

    • docker compose up --build failed for anyone with frontend deps installed
      (#60) — .dockerignore patterns match from the context root, so a bare
      node_modules/ never matched frontend/node_modules. The build context
      carried backend/.venv (153MB) and frontend/node_modules (217MB), and the
      symlink tree in node_modules/.bin failed the context load outright with
      invalid file request frontend/node_modules/.bin/acorn — so the documented
      build command did not work on a tree set up per CONTRIBUTING.md. The dev-tree
      patterns now carry **/, taking the context from 342MB to 1.3MB.

    • pip removed from the runtime image (#60) — both HIGH findings a Trivy
      scan reported against the image came from pip/_vendor (a vendored msgpack
      1.1.2 and setuptools 70.3.0, declared in pip's own bom.cdx.json), not from
      anything Iris installs. Neither was reachable, since pip is never executed in
      the running container. There were two copies to remove — the venv's and the
      base image's in /usr/local — and removing only the first left the scan
      unchanged, which is how the second was found. The image now scans clean.
      The venv's real setuptools is also upgraded off the seeded 70.3.0, which
      requirements.txt does not list and Renovate therefore could not see.

    • Reported segment count was always 1 (#62) — get_info read the segment
      count from info.leds.segs, a field no WLED firmware has ever sent, so it
      silently took the default on every controller. It now comes from
      leds.seglc, the per-segment capabilities array. The old test passed only
      because it invented the same field the code invented.

    • Two effect names in the offline picker list were wrong for the supported
      firmware
      (#62) — id 77 was labelled "Meteor Smooth", its 0.14 name, but is
      "Copy Segment" on 16.0.0 (76 and 78 are unchanged either side, so it is a
      repurposed slot rather than an offset); id 68 is "Bpm", not "BPM". A test now
      checks the whole list against the captured 16.0.0 effect table.

    • Preset sync no longer falls back to an empty preset map (#115) — a failed
      read of the controller's existing presets was swallowed, and the sync then
      built its payload on {}, dropping every preset outside the managed range —
      precisely the ones it documents itself as preserving. It now aborts with an
      explanation. Nothing was lost in practice because the write fails too:
      /json/presets is not a WLED endpoint, so live preset sync has never worked
      against real firmware. #115 tracks the rework.

    • Approving or editing a scheme now marks it user-authored (#99) — only
      community was promoted, so an approved AI suggestion kept source: "ai"
      permanently and never left the calendar's "AI-sourced" filter, which is the
      one thing that filter is for. Spec §3.3 layer 4 had always said an approved
      suggestion is "promoted to source 1"; it never was. PUT /schemes/{id} also
      left source untouched, so a scheme whose colours, effect, speed and label
      had all been replaced still reported the template it started from. Approval
      (single and bulk) and edits now all promote to user, via one shared helper
      rather than the rule being spelled out separately in each handler, and the
      edit audit records the source change. Spec §3.3 states the rule explicitly.

    • Late-night dimming no longer compounds night after night (#110) — observed
      on the dev deployment as brightness decaying 128 → 51 → 20 → 8 across three
      consecutive no-event nights. run_dim_job dimmed relative to the live
      controller value with no check that the lights were on, and the no-event path
      pushes {"on": false} — which leaves bri untouched — so nothing reset the
      baseline and each night's dim stacked on the last. The job now skips (logging
      skipped, not success) when the controller is off, and dims relative to the
      active scheme's global_brightness rather than whatever is currently set, so
      it is idempotent. Spec §5.2 already said the reduction applies "proportionally";
      the baseline was the part that was wrong. LATE_NIGHT_BRIGHTNESS is now
      documented as a percentage of the scheme's brightness in both .env.example
      and the spec — the ambiguity is what allowed two readings of the same setting.

    Downloads