-
v1.1.0
StableAll checks were successfulCI / Alembic migration check (push) Successful in 36sCI / Dockerfile lint (push) Successful in 8sCI / Pre-commit hooks (push) Successful in 1m16sCI / Python lint & type-check (push) Successful in 1m35sCI / Frontend lint, test & build (push) Successful in 1m20sRelease / Publish image & release (push) Successful in 1m41sCI / Python tests (push) Successful in 4m54sCI / Docker build, health smoke & E2E (push) Successful in 2m54sreleased this
2026-09-06 00:56:56 +00:00 | 4 commits to main since this releaseAdded
-
Demo mode (#63) —
DEMO_MODE=trueswaps 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
foundget_infoun-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 fromtests/fixtures/toapp/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
memorialtag template inschemes/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_PROVIDERis
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 sameevent_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_HOSTconfigured, 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_speedand
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_proposalsenforces, 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 crossesAURORA_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._tcpmDNS 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 settingWLED_HOSTseeds.mDNS is multicast and does not cross a Docker bridge network, so on a
defaultdocker composeinstall discovery finds nothing however healthy the
controller is.docs/setup.mdcovers 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/infofrom 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/stateand
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
withphase_config.interpolateand 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_idandpalettedo 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 newGET /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,
andMORNING_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 unlessMETRICS_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_runfor jobs,_try_pushfor pushes) rather than at each call
site, so the counters cannot drift from what actually happened. State gauges
are read from the controller status andschedule_logat 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.defusedxmlis 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/configapplies 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 throughRuntimeSettings, 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 thansort_order, sincesort_orderis 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.icsfile 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 derivingevent_keydeterministically from the
VEVENTUIDmakes 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 notical.icalendarmoves 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.icspublishes 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
DTENDthat 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, both0(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'stransitionis 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 mountedread_only: truewith a 64MB tmpfs for/tmp, all
Linux capabilities are dropped, andno-new-privilegesis 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 saysread_onlywould 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
pinDigestskeeping
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. -
hadolintgates CI;trivyscans 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.ymlalongside 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, afirmware_supportedflag and
explanation onGET /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 yields0on 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;useWledEffectsfails 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-horizonreports 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.mddocuments how to extend them, including why
they cannot currently be computed:holidaysstops at 2035 itself and
astralhas no solstice function.
Changed
-
docs/setup.md§7 anddocker-compose.registry.ymlare 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 grantspullon this repository,
and the manifest fetch returns 200. So a deployment needs no pull secret, and
docker loginis 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../datais 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 ./dataNothing in the database changes; only the file owner moves. If chowning is not
an option, setIRIS_UID/IRIS_GIDin.envto 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/effrather 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 enabledAURORA_ENABLEDwould 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.yamlwas audited at the
same time and was clean. -
An unregistered event
sourcesilently lost every priority tie (#51) —
priority_resolver._SOURCE_RANKmaps a source to a tiebreak rank and defaults
anything unrecognised to0, belowsystem. Adding the newicalsource
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 asuser, and covered by a test that fails if the entry is removed. -
transition_msis 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
answers501 {"error": 4}), soPOST /api/v1/wled/sync-presetshad never
once succeeded outside the test suite. Presets are a file on the controller's
flash filesystem: read withGET /presets.json, written by uploading that
file toPOST /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/presetsbug sit
behind green tests for the whole v1.0.0 cycle. -
docker compose up --buildfailed for anyone with frontend deps installed
(#60) —.dockerignorepatterns match from the context root, so a bare
node_modules/never matchedfrontend/node_modules. The build context
carriedbackend/.venv(153MB) andfrontend/node_modules(217MB), and the
symlink tree innode_modules/.binfailed 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. -
pipremoved from the runtime image (#60) — both HIGH findings a Trivy
scan reported against the image came frompip/_vendor(a vendored msgpack
1.1.2 and setuptools 70.3.0, declared in pip's ownbom.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 realsetuptoolsis also upgraded off the seeded 70.3.0, which
requirements.txtdoes not list and Renovate therefore could not see. -
Reported segment count was always 1 (#62) —
get_inforead the segment
count frominfo.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/presetsis 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
communitywas promoted, so an approved AI suggestion keptsource: "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
leftsourceuntouched, 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 touser, 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_jobdimmed relative to the live
controller value with no check that the lights were on, and the no-event path
pushes{"on": false}— which leavesbriuntouched — so nothing reset the
baseline and each night's dim stacked on the last. The job now skips (logging
skipped, notsuccess) when the controller is off, and dims relative to the
active scheme'sglobal_brightnessrather 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_BRIGHTNESSis 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-