Skip to content

v2.9 — mode-from-off auto-on, night-light dimming, Pedestal Fan timer & oscillation range#16

Merged
level99 merged 28 commits into
mainfrom
release/v2.9
Jun 7, 2026
Merged

v2.9 — mode-from-off auto-on, night-light dimming, Pedestal Fan timer & oscillation range#16
level99 merged 28 commits into
mainfrom
release/v2.9

Conversation

@level99

@level99 level99 commented Jun 7, 2026

Copy link
Copy Markdown
Owner

v2.9

Fix-and-polish release across the Levoit driver fleet. No new device drivers; upgrades in place via HPM (no re-pairing). One behavior change to be aware of (mode-from-off, below).

Highlights (user-facing)

Fixed

  • Mode change on an OFF device now powers it on instead of silently doing nothing — fleet-wide (Core/Vital/EverestAir/Sprout Air purifiers, all V2 humidifiers, Tower/Pedestal fans). Matches how speed/mist-level already behave; invalid modes are still rejected without waking the device.
    • ⚠️ Behavior change: if an automation deliberately sends a mode command to a device you keep off and relied on the old no-op, add an "only if switch is on" guard to keep it off.
  • Uppercase setMode("AUTO") now matched case-insensitively on Vital 100S/200S.
  • Core 200S/300S night-light dimmer now registers (a capability-declaration typo had hidden the level slider / RM "Set Level").
  • Core 300S/400S/600S no longer report auto when an auto-mode change actually failed (mode updates only on success).
  • Quieter logs: device-off write rejections and repeated failures during an internet outage now log a single WARN instead of recurring ERROR spam.

Added / Changed

  • Core 200S gains an on-demand update (refresh) command.
  • Pedestal Fan reports timerRemain, and its oscillation range now matches hardware (vertical ≤120°, horizontal ≤90°; was capped at 100°).
  • Tower/Pedestal Fan oscillation sent while off now logs a clarifying note (setting applies once powered on).

Full detail in CHANGELOG.md [2.9].

Release mechanics

  • Version lockstep 2.8 → 2.9: 24 drivers + FORK_RELEASE_VERSION; levoitManifest.json version/date/releaseNotes + bundle URL → v2.9; CHANGELOG [2.9]; ROADMAP next-release → v2.10; README "beyond" pointer → v2.9.
  • Also bundles a /cut-release skill ordering fix (open-PR-before-tag) — pipeline doc only, no driver impact.

Verification

  • lint --strict clean (RULE20 lockstep green); lint pytest 399; Spock harness 2046/2046.
  • /final-review ship gate (6 Claude lenses + Codex + OpenCode pack) — findings remediated, re-gated clean.
  • Test-hub A2 virtual-parent exercise of changed paths; prod-hub soak running clean (0 Levoit errors).

🤖 Generated with Claude Code

level99 and others added 27 commits June 5, 2026 17:02
…ck in, Gemini deferred, +§5c parallel external

Per the agent-pipeline handoff doc's latest §1 / §5b-bis / §5b-ter / §5c:

- Defer Gemini from the active fan-out (cost-uncompetitive on its required
  paid Google AI/Vertex tier vs the OpenCode pack); recipe kept dormant in
  the handoff doc. Removes the --yolo/worktree/paid-tier wiring.
- Add the OpenCode multi-model pack as the 2nd external family: family-gate
  pre-check (opencode --version + rg), dispatch ALL members if YES (no
  cherry-pick), --dir . scoped reading a pre-staged workdir diff (--dir .
  auto-rejects /tmp), ANSI-strip on parse, per-member auth-fail/cost drop,
  intra-pack split adjudication.
- Add CLAUDE.md pipeline Rule 12 (§5c): SHOULD dispatch one OpenCode
  cheap-tier reviewer alongside the Sonnet QA agent every round; reuse the
  Step-4c shared prompt; Bash-not-subagent; QA-vs-external reconciliation;
  cost ceiling.
- Stale-narration findings get extra verification (open the file/line —
  doc-drift claims hallucinate more than code claims).
- Sync CLAUDE.md reviewer table + cost cell.

Codex path unchanged. Effort/model frontmatter unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…andoff §5b-bis.5)

The handoff doc replaced the abstract pack placeholders with a named
standard 5-pack; mirror it:

- /final-review Step 4b: concrete 5-pack dispatch — mimo-v2.5-free +
  big-pickle (FREE) + deepseek-v4-flash + kimi-k2.6 + deepseek-v4-pro
  (paid Go) ≈ $0.85/run; reviewer table + cost rows updated.
- CLAUDE.md Rule 12 (§5c): pin the per-round pipeline reviewer to
  opencode-go/deepseek-v4-flash (PAID ~$0.033); free seats explicitly
  reserved for ship-gate only.
- CLAUDE.md ship-gate cost cell + reviewer row: concrete 5-pack + $0.85.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ixes headless hang)

opencode run under the Claude Code Bash tool on Windows/Git-Bash blocks on
stdin indefinitely -- it bootstraps, logs to "vcs initialized", then never
calls the model and writes 0 bytes to both streams. Redirecting stdin from
/dev/null unblocks it (empirically isolated 2026-06-05: 7 runs without it
hung, every run with it completed; confirmed on a real fan-line review at
~3 min, $0, with substantive findings).

- SKILL.md: add `< /dev/null` to all 5 OpenCode pack dispatch lines; bump
  timeout 1200000 -> 2700000 (a model was observed at ~33 min wall-clock, so
  a short timeout kills legitimate runs); document the stdin fix and that
  --dangerously-skip-permissions is a no-op Claude Code flag OpenCode drops.

Handoff doc (local) updated in parallel; the bare `> out 2>&1` form it
previously documented was never validated headless on this host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…seek-flash-free)

The handoff doc's latest revision promotes the OpenCode ship-gate pack from
5 to 6 (adds free `deepseek-v4-flash-free` as a 3rd $0 seat) and changes the
per-round iteration reviewer (§5c) from a single paid Flash to a Flash-PAIR:
paid `deepseek-v4-flash` + free `deepseek-v4-flash-free` run concurrently with
Sonnet QA, reconciled across three voices. Mirror both:

- CLAUDE.md Rule 12 (§5c): single paid Flash -> Flash-pair; reconciliation
  rewritten for three voices (Sonnet + paid + free Flash); free Flash 429 is
  benign/best-effort. Only the SLOW free seats (mimo ~2m, big-pickle ~3m) stay
  reserved for ship-gate (latency, not cost). Invocation note carries the
  mandatory `< /dev/null`.
- CLAUDE.md two-tier table + reviewer rows: 5-pack -> 6-pack, 3 free seats.
- SKILL.md: add 6th dispatch line (deepseek-v4-flash-free, FREE); 5->6 in
  counts, cost lines, and reviewer row; cost unchanged at ~$0.85 (free adds $0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…leet-wide)

Calling setMode/setAutoMode on a powered-off purifier/humidifier/fan now turns the
device on, then applies the mode -- matching how speed/level setters already behave.
Previously the command was a no-op (Vital line) or sent-but-ignored (other families),
so "set it to auto" left the device off.

- All mode setters across Core / Vital / Classic / V2-humidifier / Fan families call
  ensureSwitchOn() after input validation (invalid input never wakes an off device).
  Vital setMode converted from skip-when-off and gains case-insensitive normalization.
- Core setMode + setAutoMode commit state.mode/auto_mode/room_size and emit events ONLY
  when the cloud accepted the change (state-vs-reality); setAutoMode gains allowed-set
  enum validation before auto-on.
- BP24 classification comments on every touched setter; 8 stale RULE32 exemptions removed.
- Spock from-off + state-vs-reality regression specs added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…+ forbid git diff)

Pre-staging the review diff into the workdir is necessary but not sufficient for
sandboxed reviewers (OpenCode pack): the prompt must also positively direct the model
to read the staged file AND explicitly forbid `git diff` + scratch-writes, or some
models still reach for `git diff > /tmp/x`, get auto-rejected, and abandon the review.
Adds the named DO-NOT lines to the /final-review shared prompt + operational note, and
mirrors the rule into the CLAUDE.md §5c per-round invocation.

Source: agent-pipeline-handoff §6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ning readout

- LevoitCore200S Light: capability "Switch Level" (two words -- an invalid Hubitat
  identifier silently ignored) -> "SwitchLevel". The night-light's dimmer/level control
  now registers, so it can be dimmed from a dashboard tile or a Rule Machine setLevel
  action (previously only the explicit command worked).
- Pedestal Fan: applyStatus now emits the declared timerRemain attribute (was never
  populated -> always blank), mirroring Tower Fan; folded into the info tile when active.
- New lint rule RULE42 flags any capability name containing a space (closed-mechanism
  guard for the typo class) with must-catch / must-not-catch fixtures.
- Spock regression specs for both fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rrency mechanic)

A Claude sub-agent dispatched in its own message holds the foreground turn, so the
follow-up Flash-pair Bash calls don't fire until it's force-backgrounded -- the
"parallel" pair sits idle. Dispatch the qa Agent and both Bash calls as multiple tool
blocks in a SINGLE response (no dependency once the diff is staged). Mirrors the
handoff-doc §5c rule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ght doc

- Core 200S + Core 200S Light: drop stale "/ 300S" from driver descriptions
  (these have been separate drivers since v1.1).
- Core 200S: expose `command "update"` (on-demand refresh), matching the Core
  300S/400S/600S models; wires to the existing no-arg update() poll path.
- readme: document `update` on all four Core models' command lists; correct the
  SproutAir setNightlightMode list to on/off/dim (the driver never supported "auto").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fleet-wide, #220)

When a Levoit device is powered off, VeSync rejects bypassV2 write commands
with inner result code 11005000 (BYPASS_DEVICE_IS_OFF). Preference setters
(display, child-lock, target-humidity, drying, nightlight, color, etc.) are
NO-ON by design — they must NOT auto-power-on the device — so a scheduled or
dashboard-driven preference write to an off device is an expected non-fault,
not a hardware error. Previously every such rejection logged an ERROR and
appended a diagnostics ring-buffer record, producing recurring ERROR spam in
users' logs (originally surfaced on Superior 6000S; the shape is fleet-wide).

Fix: a stateless reportWriteFailure(tag, resp, ctx) helper in
LevoitChildBaseLib. On a write-failure branch it inspects the passed response
at call time — device-off (11005000) => one WARN (no ERROR, no record); any
other inner code / HTTP failure => the prior logError + recordError. The
decision carries NO cross-call state, so a device-off rejection on one command
can never suppress a different command's genuine error. 40 NO-ON write-failure
branches across 14 drivers route through it; SHOULD-ON intent setters
(setLevel/setSpeed/setMistLevel) are unaffected (they auto-power-on first, so
device-off is not their expected failure).

pyvesync flags 11005000 critical_error=True; this fork deliberately treats a
powered-off device as a normal user state (WARN), documented at the helper and
in the BP29 catalog entry.

Regression coverage: LevoitChildBaseLibSpec adds the stateless-helper specs
plus a load-bearing leak-regression spec (device-off on command A must not
suppress a later unrelated error on command B). Both proven non-vacuous via
orchestrator both-ways (revert => RED, restore => GREEN). VeSyncIntegrationVirtual
gains a device-off fixture op for A2 virtual-parent exercise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…c off() re-entrance guards (#247)

Two internal-consistency changes, no user-visible behavior change.

recordError ctx-key normalization: the recordError(msg, ctx) map key drifted
between `site:` (21) and `method:` (76) with no functional difference (the key
is a diagnostic label stored verbatim; nothing reads it). Normalized to
`method:` fleet-wide — 27 call sites across 9 files (a paren-balanced,
multi-line-aware scan found 6 sites a single-line grep missed). New lint rule
RULE43 (tests/lint_rules/recordError_key_style.py) enforces it going forward,
with must-catch (incl. multi-line + paren-in-message forms) and must-not-catch
fixtures; CONTRIBUTING.md example updated to match.

off() re-entrance guard symmetry: LevoitFanLib (Tower/Pedestal Fan) and
LevoitHumidifierLib (Classic 200S/300S, Dual 200S, LV600S, OasisMist 450S/1000S,
Sprout Humidifier) had a guarded on() but an unguarded off(). Added the matching
state.turningOff guard (mirroring the Vital-lib reference and the EverestAir/
SproutAir guards added earlier this batch), closing the guarded-on/unguarded-off
class fleet-wide. Defensive symmetry — no active re-entrance vector into off()
today — so a no-op under normal operation and protective under any future
toggle-race. Regression specs added to LevoitTowerFanSpec + LevoitClassic300SSpec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…xit-wedge)

Under Git-Bash, `opencode run` frequently leaves a wrapper process that never
exits AFTER the model has already written its full output — so a backgrounded
call blocks forever and its completion notification never fires (a free-audit
sweep hung ~9h with every output already on disk). The Bash-tool `timeout:`
param does not save you: a detached wrapper outlives it. The only effective
ceiling is a command-level `timeout <sec>` wrapper (output lands before it
fires, so nothing is lost).

Wrap every opencode dispatch: CLAUDE.md §5c Flash-pair invocation (timeout 1200)
and the /final-review skill's 6-pack lines (timeout 2700), with the wedge
explanation inline. Sourced from the agent-pipeline handoff doc gotcha (d).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ation following

#77 — LevoitTowerFanSpec + LevoitPedestalFanSpec: the BP23 spec asserted only that
state.turningOn was CLEARED after on() (would pass even if on() never set it). Now a
request-time metaClass hook captures state.turningOn at the moment the setSwitch request
fires and asserts it was true, keeping the cleared-after + switch=on assertions. Proven
both-ways: removing `state.turningOn = true` from LevoitFanLib.on() turns both specs RED.

#242 — RULE32 (bp24_auto_on_guard_missing) followed delegation one hop only, so an
unguarded API call reached via a 2+-hop chain (setSpeed -> helperA -> helperB) escaped.
Replaced with transitive BFS following (visited-set, cycle-safe, guard-prunes-subtree).
Added 4 fixtures (2-hop must-catch, guard-on-middle/leaf must-not-catch, cycle-safety
must-catch); existing 1-hop fixtures retained. Real-corpus finding set unchanged (0->0 --
no production gap was escaping). Proven both-ways: reverting to first-hop-only turns the
2-hop must-catch fixtures RED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…review

The OpenCode HARD free cap (FreeUsageLimitError) does not error cleanly — the
free tier swallows the provider 429 and retries internally to timeout, so the
member HANGS and returns a banner-only stub rather than a clean failure.
(Surfaced this session: both free-Flash voices returned 47-byte banner-only
stubs across two rounds — the hard cap, not transients.)

CLAUDE.md §5c: document the free-Flash no-show signature (banner-only stub =
hard cap, bounded by the timeout wrapper + benign drop); note the cheap DEBUG
cap-detector belongs to a free-only sweep pre-flight, not the per-round pair.

final-review SKILL.md: note the same hang for the 3 free pack seats — already
handled by the per-dispatch timeout wrapper + parallel-background drop at Step
6; the DEBUG cap-detector is an optional pre-drop, not a mandatory probe
(respecting the don't-burn-a-probe rule; paid seats + Claude lenses carry it).

Source: agent-pipeline handoff §5b-bis free-cap detector. Companion to the
/free-audit skill's new Step 2.5 (free-only sweep, where the probe IS
mandatory — that skill is local/global, not in this repo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…atus + 450S RGB variant (#243, #244)

Enables hardware-free A2 verification of the nightlight code paths #220 touched.
Test-harness tooling only; no driver behavior change.

#243 — LEH-B381S (Sprout Humidifier): added the driver-side setLightStatus op
(keys {brightness, colorTemperature, nightLightSwitch}, matching
LevoitSproutHumidifier.setNightlight) to tools/virtual_parent_extensions.json,
plus a nightLight response sub-object so a spawned child parses it back.

#244 — new fixture key LUH-O451S-WEU (RGB-nightlight 450S variant): the 450S
RGB nightlight is runtime-gated by isRgbVariant() == true only for deviceType
LUH-O451S-WEU, so extending the existing WUS fixture would no-op. Added the
8-key setLightStatus op {action, brightness, red, green, blue, colorMode, speed,
colorSliderLocation} (shared by setNightlightSwitch + setColor), the WEU entry
across all FIXTURE_TO_* maps + the spawn ENUM, an rgbNightLight response
sub-object, and tests/pyvesync-fixtures/LUH-O451S-WEU.yaml. WUS unchanged
(no-nightlight hardware).

FIXTURE_OPS regenerated via tools/regenerate_virtual_parent.py (--check clean,
19->20 fixtures / 130 ops). Two VeSyncIntegrationVirtualSpec A2 specs added,
proven non-vacuous both-ways (dropping a payload key -> key-set validation
rejects -> both specs RED).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… + classify oscillation setters NO-ON (#249, #127)

#249 — live capture on a real Pedestal Fan (LPF-R432S) showed the device's
oscillation angular range is wider than the driver assumed: horizontal 0-90°,
vertical 0-120°. setHorizontalRange/setVerticalRange clamped both axes to 0-100
(a guess from pyvesync's fixture sample), which silently TRUNCATED legitimate
vertical settings (a 120° down-tilt set in the VeSync app clamped to 100° on any
Hubitat range write). Corrected the clamps to the empirical maxima — H 0-90,
V 0-120 — and updated the stale 0-100 references in command-parameter
descriptions, attribute comments, and readme. safeIntArg clamp mechanism
unchanged; only the bounds.

#127 — live capture also resolved the long-deferred BP24 classification of the
5 oscillation setters (Pedestal setHorizontalOscillation/setVerticalOscillation/
setHorizontalRange/setVerticalRange + Tower setOscillation): they are NO-ON
(calling them on an off fan does NOT auto-power-on, and the cloud accepts but
does not persist an oscillation write while off — it applies only while running).
Added explicit BP24: NO-ON comments + a shared noteOscillationOffState() helper
that logs an informational note ("Fan is off — oscillation setting will apply
when the fan is powered on") when a setter is called while off; the command is
still sent (send-and-let-the-cloud-decide). Regression coverage: 5 from-off
NO-ON specs (no auto-power-on) + 4 clamp specs (90/120 bounds), all proven
non-vacuous via orchestrator both-ways (revert => RED).

ROADMAP: removed the now-resolved oscillation-classification item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…actored in #142)

The "nested-ternary speed-step" this item cited no longer exists — #142's
table-driven mapSpeedToInteger/mapIntegerToSpeed rewrite eliminated it (their
comments note the "old ternary/switch default"). The three surviving cycleSpeed
bodies are already idiomatic (Core list-rotation, Vital map lookup, Fan single
wraparound ternary); a switch would reduce readability, not improve it. Nothing
to refactor — removing the resolved item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…red hourly WARN

During a hub internet outage the parent already logs the outage once and
re-surfaces it hourly, but each child's command-triggered write-failure
branch still logged its own error + diagnostic-record on every retry, so a
device re-driven by a rule on a short cadence multiplied the noise across
every child. Route child write-failures through a new stateless
LevoitChildBase.reportWriteError(tag, ctx): while a network outage is known
it downgrades to a single DEBUG line and skips the diagnostic ring-buffer;
otherwise it falls through to the prior logError + recordError.
networkOutageKnown() is parent-null-safe (respondsTo guard + try/catch) and
reads the parent's new public isNetworkUnreachable(). httpOk() and the BP29
device-off reportWriteFailure() gained the same downgrade, with device-off
still checked first so a device-off rejection WARNs even mid-outage.

Also fixes the hourly "still unreachable" WARN (and the 5-minute reconnect
probe) occasionally firing several times within a one-minute burst: during
long polling cycles concurrent reads could see a stale last-warn timestamp
before the first write flushed. Replace the elapsed-delta timestamps with
epoch-bucket counters (now()/3600000 for the hourly WARN, now()/300000 for
the probe) so same-bucket re-reads compare equal and can't re-fire.

Regression coverage: LevoitChildBaseLibSpec proves reportWriteError /
httpOk / reportWriteFailure downgrade to DEBUG when the outage flag is set
and emit ERROR when it is clear, plus device-off-before-outage ordering and
the parent-null default; VeSyncIntegrationSpec proves emitNetworkWarnIfDue()
fires once per new hour bucket and suppresses same-bucket retries — all
proven non-vacuous both-ways. BP22 catalog entry updated to the bucket
fields + child-dedup mechanism.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The drivers #include a set of shared libraries at save time, so a manual
(non-HPM) install must create the libraries before pasting any driver or
every driver fails to save with an unresolved-library / MissingMethodException
error. The prior one-line "paste each driver" instruction omitted this.
Add the libs-first install order, the 7 library files with their
level99.<Name> #include names, the separate Libraries Code page location,
and a troubleshooting note for the unresolved-library symptom.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bug-pattern catalog had drifted into three hand-synced copies — the
numbered list in CLAUDE.md, the detailed entries in the QA agent definition,
and a dense block of rows in CONTRIBUTING.md's conventions table — so every BP
edit meant three edits that silently diverged. Consolidate to one canonical
file; everything else references it.

- docs/BUG-PATTERNS.md (new): single source of truth, BP1–BP29, each entry with
  symptom, root cause, fix scope, canonical fix, lint rule, and regression
  coverage. Numbering matches the lint-rule filenames + Spock spec names
  (BP24 = auto-on, BP25 = case-sensitivity, BP26 = safeIntArg), which are
  authoritative; CLAUDE.md's old prose list was off-by-one at #24/#25 and is
  removed.
- CLAUDE.md: inline catalog → a pointer + a lazy-load row in the "Project
  documentation files — when to load" table (read on-demand, not re-injected
  into every session).
- CONTRIBUTING.md: drop the duplicated BP rows from the conventions table (keep
  the lint-hygiene + driver-authoring convention rows); the "Bug-pattern catalog
  references" section now points at the canonical file.
- Agent defs that reason about the catalog (developer + qa + qa-coverage /
  platform / protocol / adversarial / design): a top-of-prompt "Read
  docs/BUG-PATTERNS.md FIRST" directive; the QA def's ~818-line inline catalog is
  replaced by that pointer. Mechanical / cite-only agents (preflight, tester,
  operations, operator) are left lazy by design. ('@'-import doesn't expand
  inside agent-def bodies — only in CLAUDE.md — so the explicit read directive is
  the reliable mechanism.)
- final-review external-reviewer prompt: docs/BUG-PATTERNS.md added to the shared
  "Required reading FIRST" list (covers Codex + the OpenCode pack + the per-round
  Flash pair).

Lint --strict PASS (RULE24 agent-pointer integrity intact); no driver code or
specs changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the catalog consolidation: six references still named the old
home (CLAUDE.md / the QA agent definition) for where the bug-pattern catalog
lives or where a new entry should be folded in. Repoint all at the canonical
docs/BUG-PATTERNS.md.

- CONTRIBUTING.md: "the catalog lives in CLAUDE.md (root)" and "fold it into
  CLAUDE.md on merge" -> docs/BUG-PATTERNS.md.
- .claude/commands/cut-release.md: cross-check BP references against
  docs/BUG-PATTERNS.md (was CLAUDE.md "Bug-pattern catalog").
- vesync-driver-developer.md: BP20 doc-placement pointer -> docs/BUG-PATTERNS.md.
- vesync-driver-qa-coverage.md: new-pattern entries land in docs/BUG-PATTERNS.md
  (was "CLAUDE.md AND the vesync-driver-qa definition").
- vesync-driver-qa-design.md: asymmetry-rationale BP entry -> docs/BUG-PATTERNS.md.

Lint --strict PASS; no driver code or specs changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three "User-visible polish" items shipped to release/v2.9 this cycle and no
longer belong on the unscheduled backlog:
- Child-side error-log dedup during outages (shipped: reportWriteError)
- BP22 long-outage WARN cadence + probe-interval stalls (shipped: epoch bucket)
- Manual-install path documentation (shipped: README libraries-first section)

(The community-thread OP half of the manual-install item was always external,
not a repo doc task.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bug-pattern consolidation (09380e5) inserted a top-of-body "Read
docs/BUG-PATTERNS.md FIRST" directive into the 5 ship-gate QA lenses
(coverage/platform/protocol/adversarial/design). Those defs' description:
frontmatter has always contained a ": " (colon-space, e.g. "Does NOT cover:
…") — invalid as an unquoted YAML scalar. The agent loader tolerated the
malformed frontmatter when the body began with a "# Heading" (original), but
the inserted directive as the new first body line tipped the lenient fallback
over, so the 5 agents silently stopped registering as dispatchable subagent
types — breaking the /final-review fan-out.

Fix: wrap the description: value in a proper double-quoted YAML string in
every agent def whose frontmatter failed to parse (the 5 lenses + operator +
tester, which had the same latent invalid-YAML but still happened to load).
Content unchanged; the read-FIRST directive is preserved. All 11 agent defs
now parse via yaml.safe_load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…drift, mode-from-off specs

Addresses all findings from the v2.9 /final-review ship gate (no production
behavior invariant broke; findings were library-hygiene, doc-drift, test-coverage).

- BP20: relocate the file-scope comment block out of LevoitChildBaseLib.groovy
  (between library() and the BYPASS_DEVICE_IS_OFF constant). File-scope commentary
  in a library risks Hubitat's parser rejecting the save, breaking HPM install for
  every user (all drivers #include this lib). Rationale moves into the
  reportWriteFailure method body; a one-line inline comment stays on the constant.
- BP24 catalog truth-up: docs/BUG-PATTERNS.md now classifies Vital / EverestAir /
  SproutAir setMode as SHOULD-ON (v2.9 made them auto-on-from-off; catalog still
  said SKIP-OK / "pending live-capture"). Removed the dead Vital setMode SKIP-OK
  exemptions from lint_config.yaml.
- recordError ctx key [site:] -> [method:] (RULE43) in the developer-agent
  instruction, Vital100S/200S history comments, and a spec fixture.
- RULE42 (malformed_capability) widened to catch single-quoted capability decls
  (was double-quote-only) + single-quote must-catch / must-not-catch fixtures.
- Repointed every stale bug-pattern-catalog location reference to
  docs/BUG-PATTERNS.md (oauth-flow.md, CONTRIBUTING.md, vesync-driver-operations
  agent, RULE16/24/32 lint-rule fix messages); scrubbed a ~/.claude personal path.
  grep-to-zero verified.
- readme: Pedestal Fan timerRemain row + reworded "Timer is omitted" prose.
  CHANGELOG: mode-auto-on bullet gains a "check your automations" note.
- Regression specs: invalid-mode-from-off no-wake guards on Vital 100S/200S +
  Classic 200S / Dual 200S / LV600S / OasisMist 450S, and a night-light
  SwitchLevel capability-presence spec on Core 200S Light. Proven non-vacuous
  both-ways (inverting validate->ensureSwitchOn order turns them RED).

Lint --strict PASS; lint pytest 399; Spock 2046/2046. No version bump (pre-cut).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User-facing summary (full detail in CHANGELOG [2.9]):
- mode setters auto-turn-on an off device, fleet-wide (with keep-it-off caveat)
- uppercase setMode on Vital 100S/200S; Core 200S/300S night-light dimmer registers
- Core 200S update command; Pedestal Fan timerRemain + full oscillation range
- device-off write rejections + outage retries log one WARN, not error spam

Version lockstep 2.8 -> 2.9 across 24 drivers + FORK_RELEASE_VERSION;
manifest version/date/bundle-URL/releaseNotes; CHANGELOG [2.9];
ROADMAP next-release header -> v2.10; README "beyond" pointer -> v2.9.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reorders the /cut-release Step-7 "Next steps" so the PR opens (and Gemini
fires) BEFORE gh release create, instead of after. The hard invariant was
always "asset live before the MERGE," not "before the PR" — opening the PR
early creates the review record + auto-review, and cutting the release only
after review settles means the tag captures the reviewed HEAD instead of a
pre-review commit (which otherwise forced a gh release delete + re-cut).

- Next steps resequenced: push -> open PR -> iterate review -> build bundle
  -> gh release create (tags reviewed HEAD) -> verify asset -> MERGE GATE
- Step 9 marked the one hard ordering invariant (asset 302 before squash-merge)
- ORDERING WHY + Notes rewritten to match; Artifact C.6 step cross-refs fixed
  (build 4->6, post-upload verify 6->8)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Notes bullet claimed the tagged release-branch commit "lives on main's
history (fast-forward or squash-merge equivalent)" — false for a squash, where
main gets a new commit and the tagged commit is not an ancestor of main. The
tag stays valid for HPM anyway, for an unrelated reason: the bundle resolves by
release tag (independent of main's history) and the manifest + driver source
resolve by the main branch (which carries the squashed content).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

The v2.9 release is a fix-and-polish update across the Levoit driver fleet. Key changes include standardizing mode-change behavior to power on devices, improving log signal-to-noise ratios during outages and device-off states, and enhancing Pedestal Fan reporting and oscillation accuracy. The release also includes significant internal improvements to the bug-pattern catalog and linting infrastructure.

Highlights

  • Auto-on behavior: Changing the mode of an OFF device now powers it on instead of silently doing nothing, fleet-wide. A behavior change: if an automation relied on the old no-op, add an 'only if switch is on' guard.
  • Logging improvements: Device-off write rejections and repeated failures during internet outages now log a single WARN instead of recurring ERROR spam.
  • Pedestal Fan enhancements: Reports timerRemain and oscillation ranges now match hardware (vertical ≤120°, horizontal ≤90°).
  • Bug Pattern Catalog: Moved the bug-pattern catalog to a single source of truth in docs/BUG-PATTERNS.md and added new patterns (BP28, BP29) to handle level-off ambiguity and device-off error spam.
Ignored Files
  • Ignored by pattern: CLAUDE.md (1)
    • CLAUDE.md
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@level99 level99 self-assigned this Jun 7, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the Hubitat Elevation Levoit driver codebase to version 2.9, introducing auto-on-from-off compliance (BP24) for mode-setting commands across all device families, stateless handling of device-off rejections (BP29) to eliminate log noise, and epoch-bucket cadence hardening for network outages (BP22). It also adds an update command to the Core 200S, fixes night-light dimmer registration on Core 200S/300S, populates the Pedestal Fan's remaining timer, corrects its oscillation ranges, adds new virtual fixtures, and introduces new lint rules (RULE42 and RULE43) with accompanying Spock tests. The review feedback correctly identifies a potential MissingPropertyException in the stateless isDeviceOffResp helper if the API returns a raw string response (such as an HTML error page) instead of a Map, and the suggested explicit type check should be implemented to prevent driver crashes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread Drivers/Levoit/LevoitChildBaseLib.groovy
isDeviceOffResp and the hubBypass async callback peeled resp?.data?.result?.code
with only null-guards. Groovy's ?. guards null, not type: on a non-JSON error
response (CDN/gateway HTTP 502/504 with a raw HTML body) resp.data is a non-null
String, so .result is a property access on a String -> MissingPropertyException.
hubBypass's callback is the worse site -- it runs unconditionally on every call,
no try/catch, so it threw before reportWriteFailure/isDeviceOffResp could run.
(httpOk was already safe: it peels only inside its status in [200,201,204] guard.)

Both sites now instanceof-Map-guarded before the peel. Regression specs:
String-body no-throw for isDeviceOffResp AND the hubBypass callback, plus
genuine-device-off-still-detected + normal/null nets. Both load-bearing specs
proven both-ways (revert guard -> RED with MissingPropertyException at the
named site; restore -> GREEN).

No version bump / no CHANGELOG bullet: hardens BP29 code new-and-unreleased in
v2.9; the crash was never user-reachable on a shipped version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@level99 level99 merged commit 2e09823 into main Jun 7, 2026
4 checks passed
@level99 level99 deleted the release/v2.9 branch June 7, 2026 20:54
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