Skip to content

fix: white-mix drift, WiZ speed band, optional CLI [ip], transport hardening#22

Merged
MegaManSec merged 5 commits into
mainfrom
fix/review-findings
Jun 10, 2026
Merged

fix: white-mix drift, WiZ speed band, optional CLI [ip], transport hardening#22
MegaManSec merged 5 commits into
mainfrom
fix/review-findings

Conversation

@MegaManSec

Copy link
Copy Markdown
Owner

Fixes from a full code review, verified end-to-end. Three scoped commits: core, cli, macos.

The four headline bugs

  • "Brighter colours" washed every colour to white. The app folded the display-only perceived colour into state.rgb (the send path) — pick a pastel with white-mix on, wait one health poll, touch any control, and the light went pure white. The engine now exposes whiteMixedToRgb, the exact inverse of rgbToWhiteMixed (our split always drives c/w equally), and the app/CLI invert first, falling back to the perceived approximation only for an uneven foreign split — now a stable fixed point. Verified: the previously drifting round trip is byte-identical.
  • Scene speed band was 1–100; the firmware takes 10–200 (official app / pywizlight, 100 = normal). parsePilot clamped a reported 150 to 100 (UI misread it; a re-send slowed the animation), and the CLI help's own example --speed 120 failed its own validator. Engine constants fixed; macOS sliders now read the band from the engine; help/docs updated.
  • The documented optional [ip] never worked for any CLI command with another positional — wiz color ff0000 errored with "Not a valid IPv4 address: ff0000". The first positional is now the IP only when it parses as one; an IP-shaped typo (10.0.0.999) still fails loudly; leftover args are rejected. Bonus: multi-word preset/save names work unquoted, and the CLI README documented a third (trailing-ip) order that never worked — now matches --help.
  • Sleep power-off race. A debounced colour edit from just before lid-close could fire ~250 ms after the forced off and relight the bulb. WizClient now mirrors the engine's send generation (newer sends abandon in-flight retries; superseded debounced payloads are dropped) and sendPowerOffNow cancels pending sends synchronously first. Same supersede semantics added to the JS WizLight.

Hardening + smaller fixes

  • Both transports accept query replies only from the queried host (the ephemeral UDP port was open to any sender).
  • stateMatchesPreset never matches an off light or during a scene.
  • removeSavedLight actually forgets last_ip (new clearLastIp; saveLastIp("") guards empty and silently no-opped) and the startup fallback light is deterministic (Dictionary order is randomized per process).
  • Discovery: mac is "" (never undefined) in both implementations; point-to-point/VPN interfaces excluded from broadcast targets; discovery-sheet rows can't collide on a blank MAC.
  • Stray-window guard no longer closes NSPanels (future color panels/alerts); colour-wheel CGContext pointer UB fixed via withUnsafeMutableBytes; --timeout 0.5 rejected instead of becoming a 0 ms window; swatch spacing/doc fixes; preset listings sorted identically in both front-ends.
  • Docs: Stores.swift now states the App Sandbox reality — the released app and the CLI share the on-disk format but not the same files (the app's copy lives in its container).

Verification

  • 215 JS tests (incl. new coverage for the inversion, speed band, source checks, supersede), eslint + Prettier clean.
  • swift build + 17 Swift tests pass; JSCore bundle regenerated and verified in sync.
  • CLI behaviours re-verified live against a temp data dir (optional ip, speed 120, typo'd ip, extra args, timeout 0.5).

- SPEED_MIN/SPEED_MAX were 1-100; the WiZ firmware band (per the official
  app and pywizlight) is 10-200 with 100 = normal. parsePilot was clamping
  a reported speed of e.g. 150 down to 100, so the UI misread it and a
  re-send actually slowed the animation - and the CLI's own documented
  example (--speed 120) failed its validator.
- Add whiteMixedToRgb, the exact inverse of rgbToWhiteMixed: a pilot whose
  white channels carry an equal c/w split (the signature of our own mix)
  reconstructs the colour that was set. Folding the *perceived* colour back
  into state and re-sending it washed every colour toward pure white - the
  drift color.js explicitly warns about.
- query() now ignores datagrams that didn't come from the queried host;
  the ephemeral port is otherwise open to any sender on the LAN.
- A debounced WizLight.send armed before a direct sendNow is dropped when
  its window elapses, so a stale payload can't land after a forced send.
- stateMatchesPreset never matches while the light is off or a scene runs.
- discover() reports mac as '' (never undefined), matching the Swift side.
- Regenerate the committed JSCore bundle from the changed engine.
The help has always promised "<ip> is optional everywhere - it falls back
to the last-used bulb", but every command with another positional (color,
temp, brightness, preset, scene, save) parsed its first argument as the IP
and errored: `wiz color ff0000` -> "Not a valid IPv4 address: ff0000".

- resolveTarget treats the first positional as the IP only when it parses
  as one; a string merely shaped like an IP (10.0.0.999) still fails
  loudly, and fixed-shape commands reject leftover arguments.
- Multi-word preset/save names ("Full White", "Living Room") now work
  unquoted, like scene names already did.
- --speed help matches the corrected 10-200 band (the example works now).
- Preset listing is sorted by name, the same stable order the macOS app
  shows, instead of raw file order.
- --timeout/--attempts floor before the bounds check so 0.5 is rejected
  instead of becoming a 0 ms listen window.
- wiz discover skips the MAC segment instead of printing "undefined";
  status/color compose the swatch without a double space when colour is
  off; the wiz status swatch prefers the exact white-mix inverse over the
  perceived approximation.
- README: document the leading-[ip] order (it previously showed a trailing
  [ip] form that never worked) and the scenes/scene commands.
- foldedState inverts the bulb's equal c/w split exactly (the engine's new
  whiteMixedToRgb) instead of folding the display-only perceived colour
  into state.rgb. Previously: pick a pastel with "Brighter colours" on,
  wait one health poll, touch any control - the light went pure white.
  An uneven (foreign) split still falls back to the perceived fold, which
  is now a stable fixed point rather than a feedback loop.
- WizClient mirrors the engine's send generation: a newer sendNow abandons
  an in-flight retry loop, a debounced payload superseded by a direct send
  is dropped, and sendPowerOffNow cancels pending sends synchronously - so
  a colour edit from just before the lid closed can't fire after the off
  and relight the bulb. apply(state:params:) drops its unused parameter
  and becomes send(_:), matching the engine naming.
- query() accepts replies only from the queried host (mirrors the engine).
- Scene-speed sliders take their band from the engine (10-200, 100 =
  normal) instead of a hard-coded 1-100.
- removeSavedLight clears last_ip via the new Stores.clearLastIp -
  saveLastIp("") guards empty input and silently did nothing - and the
  startup fallback light is picked deterministically by name (Dictionary
  order is randomized per process).
- Stores documents the App Sandbox container location: the released app
  and the CLI share the on-disk format but not the same files.
- Misc hardening: the stray-window guard no longer closes NSPanels (color
  panels, alerts); the colour-wheel CGContext lives inside
  withUnsafeMutableBytes (the &pixels pointer was only valid for a single
  call); discovery skips point-to-point/VPN interfaces (IFF_BROADCAST);
  discovery-sheet rows are identified by value so MAC-less replies can't
  collide; dropped replies without a MAC render consistently.
Bump the root, core, and cli package versions and the app's
CFBundleShortVersionString to 5.2.0 in lockstep (CFBundleVersion 19 -> 20),
so the release workflow publishes on merge to main. Ships the white-mix
read-back fix, the corrected 10-200 scene-speed band, the truly optional
CLI [ip], and the transport/power-off hardening.
@MegaManSec MegaManSec merged commit 19a4091 into main Jun 10, 2026
2 checks passed
@MegaManSec MegaManSec deleted the fix/review-findings branch June 10, 2026 06:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant