• 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
  • v1.0.0 0251a6ab9b

    v1.0.0
    All checks were successful
    CI / Alembic migration check (push) Successful in 39s
    CI / Frontend lint, test & build (push) Successful in 1m12s
    CI / Pre-commit hooks (push) Successful in 1m29s
    CI / Python lint & type-check (push) Successful in 1m51s
    CI / Python tests (push) Successful in 1m58s
    Release / Publish image & release (push) Successful in 2m59s
    CI / Docker build, health smoke & E2E (push) Successful in 5m28s
    Stable

    claude-bot released this 2026-09-01 19:59:22 +00:00 | 29 commits to main since this release

    Added

    • CI runs the pre-commit hooks (#58) — .pre-commit-config.yaml existed but
      nothing executed it, so five of its seven hooks (whitespace, EOF, YAML,
      merge-conflict and large-file checks) had no equivalent anywhere and were
      enforced only where a contributor had run pre-commit install. A Pre-commit hooks job now runs them all. Three things surfaced on first execution:
      check-yaml was failing on docker-compose.registry.yml (Compose's !reset
      tag is not standard YAML — now excluded, Compose validates that file itself);
      the ruff hook was pinned to 0.6.9 while the backend resolved 0.15.21, a skew
      that would show as one job failing on formatting another considers clean (now
      pinned together, with Renovate's pre-commit manager enabled to keep them
      moving); and the prettier hook was removed, having never run, not being a
      frontend dependency, and sourcing from an archived upstream mirror — it would
      have reformatted 62 files. Frontend style remains gated by ESLint's
      --max-warnings 0.

    • Browsable WLED preset import (#48) — spec §3.6 has always called for parsing a
      presets.json and "displaying them in a browsable import panel" where the user
      "selects which presets to import"; what existed was a textarea, a dropdown, and one
      preset per round trip. The Import dialog is now a file picker (paste still available)
      over a scrollable list showing each preset's colour swatch, name and source segments,
      with a checkbox and per-row event/phase selector. Segment mapping is a row per source
      segment mapped to this install's named segments, replacing the raw JSON field.
      Collisions resolve against the current library before sending — an approved slot
      blocks, a candidate warns it will be replaced — because the new
      POST /import/presets/batch applies the whole selection in one transaction under a
      single backup, so one bad row would otherwise discard every good one.

    • Quick push accepts an arbitrary colour, not just a scheme ID (#48) — the spec
      has always described quick push as pushing "an arbitrary color or scheme", but only
      the scheme half was implemented. POST /wled/quick-push now takes either
      scheme_id or color (an RGB triple) and rejects a body carrying both or neither;
      the colour is applied solid to every themed segment with effect and palette forced
      to 0. No Scheme row is involved, so an ad-hoc push leaves nothing behind in the
      library, and the revert paths (next scheduler event, duration_mins, manual cancel)
      are identical either way. The toolbar's Quick push dialog gained a Scheme / Colour
      tab pair with a colour picker.

    • Scheduled daily DB backup with a tested restore path (#57) — a new 04:00
      quiet-hour job (daily_backup) snapshots the SQLite DB every day, independent of
      bulk operations, retained via DAILY_BACKUP_RETAIN_COUNT (default 14) separately
      from BACKUP_RETAIN_COUNT (pre-bulk-op backups). Backups now use SQLite's online
      backup API (sqlite3.Connection.backup) instead of a raw file copy, so a live DB
      is never copied mid-write. The restore procedure is documented in
      docs/troubleshooting.md and exercised by an automated test
      (test_restore_from_backup_recovers_original_data).

    • A :main image is published on every merge (#64) — after the health smoke
      test and E2E pass on a push to main, ci.yml pushes to
      git.rhoving.com/rbrooks/iris-wled as :main and :sha-<short>. The image
      is the one the E2E ran against, retagged rather than rebuilt, so the published
      artifact is bit-for-bit what was tested. Registry auth uses the built-in
      Actions token; pull-request runs never authenticate. This is what lets the dev
      server deploy by pulling instead of building on the host (#65).

    • Deploying from the published image (#65) — docker-compose.registry.yml
      runs git.rhoving.com/rbrooks/iris-wled:${IRIS_TAG:-main} instead of building
      on the host, with IRIS_TAG selecting a :sha-<short> tag to roll back to a
      specific commit. It is a dedicated file, not docker-compose.override.yml, so
      the default docker compose up -d still builds from source and the README
      quickstart is unaffected. The deploy sequence is documented in
      docs/setup.md §6.

    • Event data pipeline — unified events model with stable event_keys and self-referential
      overrides. Recurrence resolvers for fixed, nth_weekday, hebrew (pyluach), islamic
      (hijri-converter), hindu_lunar, computed (Easter, Lunar New Year, solstices/equinoxes), and
      span. Integrates the holidays library, bundled config/events.yaml, mounted custom events,
      and an optional Calendarific layer. Priority resolution (month-long backdrop vs. single-day
      override), per-date primary pins, user-event shadowing, and a current+next-year cache.

    • Segments & schemes (data layer) — segment CRUD, phase resolver
      (eve/opening/mid/closing), scheme resolution order (user-saved → built-in library by
      event_key/tag), daily preset generation per (event, phase), preset invalidation with
      candidate auto-regen and approved-preset stale flagging, a typed settings service, an audit
      writer, and a timestamped-backup service with retention pruning.

    • WLED integration — a single-device WLEDController abstraction (structured for future
      multi-controller support), httpx client with timeout/retry, JSON state/info/presets wrappers, a
      scheme→WLED translator, single + carousel preview with auto-restore, quick-push with auto-revert,
      bounded preset sync with capacity safety checks, a connection-status tracker, and configurable
      fallback policies (retry / last_known_good / silent).

    • Scheduler & time envelope — timezone-aware APScheduler with a persistent job store; solar
      scheduling via astral and fixed-clock scheduling; daily on / late-night-dim / off jobs; a weekly
      candidate-generation job; a yearly Jan 1 backup + full-year candidate job; a schedule_log; and
      fallback retry wrapping every push. DST and tz-change handling covered by tests.

    • Authentication & authorization — OIDC via Authlib, server-side sessions, HttpOnly/Secure
      cookies with CSRF state handling, role extraction with require_admin/require_viewer guards on
      every endpoint, a DISABLE_AUTH=true dev mode with a UI banner, and authenticated actor identity
      bound into the audit log with before/after diffs.

    • Year review UI — generated TypeScript API types, a TanStack Query client, an app shell with
      connection status / year selector / alerts / user menu, an auth guard, a 12-month calendar grid
      with month-long backdrop bars and per-segment gradient strips, a day detail panel, an inline scheme
      editor (per-segment HSV/RGB pickers, effect picker, sliders, save-as-named, live preview), action
      buttons, a settings page, filter chips, bulk approve, quick push, a toast/alert surface, and a
      responsive accessible theme. Covered by Vitest component tests and a Playwright happy-path E2E.

    • AI suggestion pipeline — a SuggestionProvider abstraction with Ollama, Anthropic, and
      OpenAICompatible implementations, a provider factory, a context-rich prompt builder with few-shot
      examples, Pydantic response validation with one retry then built-in fallback, and a rolling-24h
      request counter enforcing AI_DAILY_LIMIT.

    • Notifications — an async webhook dispatcher with HMAC-SHA256 signing, verbatim custom headers,
      per-endpoint event subscriptions, retry with backoff, wired to scheme_pushed, push_failed,
      review_queue_ready, and quick_push, plus a webhooks settings page with a test-send button.

    • Import / export & built-in contentpresets.json export (full + per-event), WLED
      presets.json import with a pre-import backup and a segment-mapping wizard, and a built-in scheme
      library covering every bundled event with tag-based templates and headline event_key entries.

    • Hardening & release readiness — a Docker HEALTHCHECK, compose resource-limit guidance,
      structured JSON logging, an optional env-gated Sentry hook, a setup guide (docs/setup.md), a
      troubleshooting guide (docs/troubleshooting.md), an OIDC/OWASP-ASVS review, and dependency,
      bundle-size, and query-index audits.

    Changed

    • The preview carousel cycles upcoming schemes, not one day's (#48) — it
      previously ran over [scheme.id], the selected day's single scheme, which is
      a one-element carousel and duplicated the Preview button beside it. Starting
      one moved to the toolbar, where a window (next 7 / 30 / 90 days) is a
      meaningful choice; "upcoming" is not a property of whichever day is selected.
      It walks the resolved day map so it shows what will actually run — no-event
      days contribute nothing and a multi-day event appears once, not once per day —
      and it is available to viewers, since the spec allows them to trigger bounded
      previews. The stop control stays in the side panel and header.

    • Preset export is available to viewers, not just admins (#48) — the spec's
      rule for the API surface is that "GET endpoints allow viewer unless noted
      otherwise", and neither export row is noted admin-only (unlike /audit-log).
      Both GET /export/presets.json and GET /export/events/{id}.json now take
      require_viewer, and the toolbar's Export button moved outside the admin-only
      block — un-gating the API alone would have left viewers with no way to reach it.

    • Single-platform container images (#79) — Iris now publishes linux/amd64
      only. The linux/arm64 half of the build is gone from ci.yml and
      release.yml, together with the QEMU emulation step and the redundant second
      image build that ran on every push to main. Spec §13.4 had called arm64 "a
      hard requirement" on the assumption that self-hosters would run Iris on a
      Raspberry Pi, but no arm64 image was ever run or validated. Iris is an HTTP
      client to the WLED controller with no affinity to the lighting hardware, so it
      runs on any always-on host on the LAN. Re-adding a platform is a one-line
      platforms: change in the two build workflows.

    • CI cancels superseded runs (#78) — ci.yml gains a top-level concurrency
      group keyed on workflow + ref with cancel-in-progress, so a new push
      supersedes the previous run instead of both occupying the runner pool shared
      across every repo on the instance. Because the E2E cleanup is run-scoped and a
      cancelled job is not guaranteed to reach it, the docker job now also sweeps
      iris-*/pw-* containers and iris-net-* networks older than 2h — old enough
      that they cannot belong to a live concurrent run.

    • Dependency auditing moved out of CI onto a schedule (#81) — pip-audit
      and npm audit now run weekly from .forgejo/workflows/audit.yml instead of
      as a job in every CI run. The job was marked continue-on-error: true and
      described as advisory, but on this runner a failing continue-on-error job
      still marks the whole run failure — so every CI run was red regardless of
      the change under test, and enabling #56 (require green CI before merge) would
      have blocked every merge, including whenever a new CVE landed on a transitive
      dependency. The audit now reports on its own schedule and gates nothing.

    Fixed

    • The lights are switched off again (#106) — the nightly reschedule was
      cancelling the pending sunrise-off before it could fire, so Iris never turned
      the lights off at all: they came on and dimmed on schedule, then stayed lit
      through the following day. A lighting day runs sunset→sunrise, not
      midnight→midnight, but the schedule was computed from the calendar date, so
      the 00:01 reschedule recomputed the off as tomorrow's sunrise and — because
      every run writes the single off_job id with replace_existing=True
      discarded the one due a few hours later. The failure was silent: a job that
      never runs writes no log row, and every job that did run reported success.
      New get_active_times() carries a still-pending off forward from the previous
      cycle; /schedule/status uses it too, so the reported off is the one that will
      actually fire.

    • Playwright image tag now follows the lockfile (#38, #39) — ci.yml pinned
      mcr.microsoft.com/playwright:v1.60.0-noble while package.json declares
      @playwright/test: ^1.47.0. Any Renovate bump inside that caret range left the
      browsers baked into the image out of step with the package npm ci installed,
      and every E2E test failed with "Executable doesn't exist ... Please update
      docker image as well"
      . The tag is now read from
      frontend/package-lock.json at run time, so the image and the package cannot
      drift apart again. Resolves the E2E failure blocking the Renovate queue.

    • shadcn/ui primitives no longer fail lint on fast-refresh warnings (#35) —
      newer eslint-plugin-react-refresh releases emit
      react-refresh/only-export-components for the vendored src/components/ui/
      files, which export components alongside re-exported Radix parts. With
      npm run lint running at --max-warnings 0, 15 warnings and zero errors
      failed the build. The rule is now off for src/components/ui/** only, and
      stays on for all application code.

    • Year-rollover job completes the boundary refresh — the Jan 1 yearly_job now
      populates event sources into the DB and warms the in-memory year cache for the new
      and next year before regenerating candidate schemes, so rolled-over events are
      queryable without a restart (M1/M8). It also fires the review_queue_ready webhook
      on completion, matching the weekly candidate job (M4). Previously the job only ran a
      backup and scheme regeneration, leaving next-year events absent until a restart.

    Security

    • The dependency audit is clean again (#81) — npm audit --audit-level=high
      reports 0 vulnerabilities. Four of the six advisories cleared through ordinary
      Renovate bumps (react-router-dom v7, nanoid, postcss, and the
      typescript-estree route to brace-expansion). The last two were reachable
      only through eslint's own dependency tree at the latest published eslint —
      minimatch@3 → brace-expansion and @eslint/eslintrc → js-yaml — so no
      dependency PR could ever have fixed them. Both are pinned to patched in-range
      versions via overrides, scoped so the healthy brace-expansion@5 used by
      @typescript-eslint is untouched. Renovate does not manage overrides, so
      removal conditions are tracked in #101 and documented in CONTRIBUTING.md.

    Infrastructure

    • CI moved to Forgejo Actions — lint, type-check, backend + frontend tests, E2E, dependency
      audits, a migration-consistency check, and a Docker image build with a booted-container health
      smoke test, all running on git.rhoving.com.
    • Authoritative imageslinux/amd64 images published to the Forgejo container registry at
      git.rhoving.com/rbrooks/iris-wled.
    • Release automation — pushing a vX.Y.Z tag builds and publishes images and creates the
      matching Forgejo release from this changelog.

    Deferred

    • Progressive HSV phase interpolation (spec §4.2) — deferred to v1.1. The scheme
      generator emits one preset per phase (eve/opening/mid/closing) with colors
      resolved independently per phase; smooth HSV interpolation across phases requires a
      per-day preset model and per-event opt-in and is tracked as a v1.1 follow-up (see the
      note in services/scheme_generator.py). Not half-implemented.

    Year-rollover QA (#8) passed against a clock-shifted instance. The end-to-end operational
    dry run (#7) verified every leg against real hardware — configure/approve, push, the
    scheduler's on/dim/off, and all three fallback policies; it stays open under v1.1.0 only
    to confirm one ordinary unattended night, the mechanism itself having been reproduced and
    fixed (#106). HSV phase interpolation is deferred to v1.1 as described above. The v2
    backlog (scheme version history, Home Assistant/MQTT, multi-controller) is tracked under
    the v2.0.0 label.

    Downloads