v2.2: LV600S + Dual 200S drivers, EU region (preview), RGB nightlight WEU, BP14 + BP16 reboot-survival fixes#4
Conversation
Adds a contributor-facing doc covering codebase tour, dev environment setup, conventions enforced by lint/tests, test-runner usage, PR flow, preview-driver protocol, and additional resources. Despite the name, CONTRIBUTING.md is the canonical onboarding for BOTH human and AI-assisted contributors -- most of the content (codebase orientation, pyvesync as canonical source, lint rule reference, test runner gotchas, PR mechanics) is general contributor knowledge that wasn't in canonical form anywhere before. CLAUDE.md remains the AI-pipeline overlay (dev/QA/tester agent dispatch, resume protocol, cost-optimization rules). A follow-up commit will slim CLAUDE.md by removing the codebase-tour / canonical-reference / new-device-flow sections now duplicated in CONTRIBUTING.md. 10 sections / ~320 lines: 1. Welcome + contribution types + where to file what 2. Codebase orientation + architecture + pyvesync + audience 3. Dev environment setup 4. Adding a new device driver (incl. preview convention + BP9) 5. Fixing a bug (workflow + bug-pattern catalog reference) 6. Conventions enforced by lint/tests (high-leverage table) 7. Running tests locally (single-spec, lint output, gotchas, CI parity) 8. Opening a PR (commit style + CI gates + bot review) 9. Working on a preview driver (validation + refutation paths) 10. Additional resources + License Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sync / new-device-flow to CONTRIBUTING.md CLAUDE.md is now the AI-pipeline overlay on top of CONTRIBUTING.md (commit 93a4a90). Removes content duplicated in CONTRIBUTING.md; keeps AI-only mechanics + load-bearing patterns dev/QA agents reference directly. Removed (covered by CONTRIBUTING.md): - "Codebase summary" + Layout tree + Architecture paragraph -> CONTRIBUTING.md "Codebase orientation" + "Architecture in one paragraph" - "Canonical references" (pyvesync hierarchy + Hubitat docs) -> CONTRIBUTING.md "Canonical reference: pyvesync" + "Additional resources" - "Adding a new device" (10-step flow) -> CONTRIBUTING.md "Adding a new device driver" (11-step superset; adds lint_config.yaml frozen_driver_names + Spock spec steps) - VeSync API trace closure bullet from "Source references" (later restored as NIT 3 -- see below) Kept (AI-only or load-bearing for agents): - The pipeline (HARD rule) -- agent dispatch + resume - Two deployment contexts (with/without MCP) - Logging conventions table + Pref-seed insertion-point table - Bug-pattern catalog (canonical numbered list) - Common-contributor-tasks -> which agent table - GitHub workflow (gh-defaults-to-upstream gotcha) - Source references (sanitize, envelope-peel, raw-response, 3-sig update, trace closure) - Cost optimization notes - When this file is wrong New preamble at top: states CLAUDE.md is the AI-pipeline overlay, assumes CONTRIBUTING.md is read, releases human contributors after the first paragraph. Net: 426 -> 348 lines (-78 lines, -18%) after compensating restorations. QA APPROVE'd the original slim-down at -90 lines/-21% with 3 non-blocking NITs about narrowed coverage. All 3 NITs addressed in this same commit to land cleanly: NIT 1 (CONTRIBUTING.md): added one paragraph to "Canonical reference: pyvesync" stating pyvesync may lag the live API and the diagnostic raw-response log line is ground truth on conflict. NIT 2 (CLAUDE.md): restored the 5-line pref-seed snippet as a code block beneath the per-driver insertion-point table. NIT 3 (CLAUDE.md): restored the VeSync API trace closure bullet to "Source references" with the 1-line/full-body summary detail. CONTRIBUTING.md row in the project-documentation-files table updated from "(when present, post-v2.0)" + sparse trigger phrases to substantive spec with full trigger-phrase list and **"Read first when contributing"** emphasis. CODE_OF_CONDUCT.md row simplified. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…leanups Three fixes to top-level README: 1. HPM master-index qualifier dropped. The master-index PR (HubitatCommunity/hubitat-packagerepositories #102) was manually merged 2026-04-26; both install paths now work equally. "(preferred -- once the master-index PR merges)" updated to just "(preferred)". 2. Architecture paragraph voice fix. "holds your VeSync credentials" read as second-person addressing the user, but the paragraph is describing what the parent driver does objectively. Updated to "holds the VeSync account credentials" -- matches the third-person voice in CONTRIBUTING.md's parallel architecture paragraph. 3. Credit line cleanup. "v1.6+ contributions, Vital 100S/200S, ..." read awkwardly mixing the v1.x fork-base era with v2.X contributions. Simplified to "community fork maintainer (v2.0+); Vital 100S/200S, ..." -- v2.0 was when the fork properly took over. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rences Adopts the Contributor Covenant 2.1 -- the de-facto OSS standard (Linux kernel, Rails, Eclipse, Apache, GitLab default; CC-BY-4.0). Verbatim canonical text with one substitution at the Enforcement section: contact method points to GitHub issues for general matters and direct DM to https://github.com/level99 for sensitive/private reports. No personal email surfaced (already public on profile; omitting from CoC line dodges spam-crawler indexing). Cross-references added so the CoC isn't an orphan file: - README.md: Contributing section rewired -- CONTRIBUTING.md is now the primary contributor onboarding pointer, CLAUDE.md noted as the AI-pipeline overlay. CoC linked alongside. - README.md: Niklas credit gains a GitHub profile link. - CONTRIBUTING.md: "Sensitive incident reports" bullet now references CoC for the formal escalation framework (the same DM channel, with the 4-tier Correction -> Warning -> Temporary Ban -> Permanent Ban scaffolding spelled out in the CoC for clarity if it's ever needed). - CLAUDE.md: project-documentation-files table CoC row drops "(when present)" qualifier and gets a richer trigger-phrase set. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the if/else-chain in updateDevices() with a centralized @field static final Map<String,String> TYPENAME_TO_METHOD lookup. Single source of truth for family -> API-method routing; adding a new driver family becomes "add one map entry" instead of "add an elif branch." Closes Gemini Code Assist suggestion from PR #2 (line 362, discussion r3144563216) -- approach (a) from GitHub issue #3. Drivers/Levoit/VeSyncIntegration.groovy: - New @field constants block (~lines 118-129): TYPENAME_TO_METHOD: Map of typeName-substring -> API method DEFAULT_POLL_METHOD: "getPurifierStatus" (Elvis fallback) - Routing logic in updateDevices() collapses 11 lines (4-branch if/else) to 2 lines (find + Elvis): String typeName = dev.typeName ?: dev.name ?: "" String method = TYPENAME_TO_METHOD.find { typeName.contains(it.key) } ?.value ?: DEFAULT_POLL_METHOD - Comments cite issue #3 + Bug Pattern #9 / RULE19 rationale for why typeName substring keys are stable (driver names frozen once shipped). src/test/groovy/drivers/VeSyncIntegrationSpec.groovy: - New H7 test: asserts "Levoit Generic Device" routes to getPurifierStatus via the default fallback. Three negative assertions confirm no other family method was called. Behavior bit-identical for all 13 shipped driver typeNames (verified: Tower Fan -> getTowerFanStatus, Pedestal Fan -> getFanStatus, all *Humidifier -> getHumidifierStatus, all *Air Purifier and Generic Device -> getPurifierStatus default). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…resume Two cost-discipline changes: 1. QA agent default model: Opus -> Sonnet (frontmatter). Elevation to Opus available per-dispatch via `model: "opus"` for a specific subset documented in the new "QA dispatch: model selection" section (auth/credential paths, new bug patterns, multi-pattern interactions, 3rd+ iteration drift, substantial parent routing changes). 2. Pipeline Rule 3 rewritten. Replaces "always try resume first" with a cache-warmth-aware decision: capture timestamp via `date -Iseconds` at each agent dispatch, compute delta on next need, choose resume vs fresh based on time-since-last-dispatch and topic relevance. Applies to all 4 agents (dev / qa / tester / ops). CLAUDE.md changes: - Pipeline diagram: QA model annotation updated - Pipeline Rule 3 rewritten (cache-warmth heuristic + decision table) - New section "QA dispatch: model selection" before "Two deployment contexts" - Cost-optimization-notes paragraph updated to reflect new defaults Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 4 agent definitions accumulated duplicate content as v2.0/v2.1 shipped: each carried its own (now-stale) repo-layout tree and parent-child architecture overview, plus dev/qa duplicated CLAUDE.md's logging-conventions table verbatim. Replaced inline copies with pointers to the canonical sections: - Repo layout & architecture -> CONTRIBUTING.md "Codebase orientation" + "Architecture in one paragraph" - Logging conventions table + helper pattern -> CLAUDE.md "Logging conventions" Per-agent-def deltas: vesync-driver-developer.md 343 -> 304 (-39 lines) vesync-driver-qa.md 464 -> 438 (-26 lines) vesync-driver-operations.md 212 -> 200 (-12 lines) vesync-driver-tester.md 233 -> 233 (no dup; skipped) Bonus: the layout trees we removed were stale -- missing the v2.1 drivers (Classic 300S, OasisMist 450S, Tower/Pedestal Fans, Generic). Pointer model means agents always see current layout via the auto-injected CONTRIBUTING.md. Not touched (intentionally -- distinct artifacts, not duplication): - Dev's bypassV2 envelope + V201S/S6000S/Core payload tables (dev specialist content) - QA's review-checklist sections A-J (operational checks, not overview) - QA's BP1-13 catalog with code (operational detail vs CLAUDE.md's index summary) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Approved during v2.2 doc cycle (release/v2.2 review of CONTRIBUTING.md landing) but never landed in the cut-release spec. CONTRIBUTING.md section "Codebase orientation" carries a driver-file tree that drifts every release; sections "Adding a new device driver" / "Conventions enforced by lint/tests" / "What's still gappy in shipped previews" also accumulate stale references release-over-release. Artifact F is a release-time audit (not auto-applied -- flagged for maintainer eyeball) checking those four drift surfaces against the current code/lint/manifest/preview state. Surfaced by the agent-def slim-down review: the layout trees we just removed from .claude/agents/*.md were stale (missing v2.1 drivers) for exactly this reason -- no audit step caught the drift. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 6 applies Artifact F edits to CONTRIBUTING.md but Step 7's "Files staged-ready" enumeration was missing the corresponding line. Maintainer might forget to git-add it after running cut-release. Trailing fix to commit 717d4ae (Artifact F). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaced by QA's BP14 round-1 self-analysis: spec file diffs were ~40% of file-read cost on that review. given: blocks (mocks, fixture load, captured-variable defs) are scaffolding the tester implicitly validates by virtue of compile + execute; QA's semantic-correctness review only needs the then:/and:/expect: assertion blocks. Adds a "Cost discipline -- reading spec diffs" section to the QA agent def with a grep targeting pattern and a "read full given: only on FAILING specs" exception. Estimated savings: ~50% of spec-file read on diffs that include spec changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug: parent driver's updateDevices() previously used recursive runIn() to schedule the next poll. runIn() is in-memory only and does not persist across hub reboots. After a reboot between two polls, the chain broke and polling stayed dead until the user clicked Save Preferences (or any command fired updated()). Diagnosed 2026-04-27 on the maintainer's hub: getHubJobs confirmed zero updateDevices entries in scheduledJobs after the daily 02:15 MDT reboot. Real shipped bug -- inherited from Niklas's upstream (commit dc4b4a8, 2023-02-05) and never caught upstream. Fix: - Replace recursive runIn() with schedule(cron, ...) which IS persisted across reboots. New private setupPollSchedule() centralizes this. - Self-heal block ensurePollWatchdog() called from updateDevices() AND sendBypassRequest() migrates pre-v2.2 installs on first poll or first command (whichever fires sooner). - subscribe(location, "systemStart", "reschedulePoll") gives a defensive belt-and-braces re-arm at boot time. - forceReinitialize() now calls unsubscribe() before state.clear() to avoid subscription stacking on repeated rescue invocations. - Bug Pattern #14 added to CLAUDE.md catalog and CONTRIBUTING.md conventions table. Migration coverage: - Polling alive on update: next poll migrates automatically. - Polling dead post-reboot: any command/automation triggers ensurePollWatchdog via sendBypassRequest -> migration -> resurrection. - Idle hub, no user action for days: stays broken (Hubitat platform limitation, no driver lifecycle method auto-fires on idle hub). CHANGELOG documents this case with a Save-Preferences fallback. - Fresh install: installed() -> initialize() -> setupPollSchedule(). Test coverage: 9 new Spock specs in VeSyncIntegrationSpec Section I covering all migration paths, idempotency, ordering invariants, and the forceReinitialize subscription-leak fix. HubitatSpec base mocks extended with no-op schedule/subscribe/unsubscribe stubs and varargs unschedule. All 456 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… reboot" This reverts commit 8cd4da6. Live deploy on the maintainer's hub failed -- ops returned FAIL. subscribe(location, "systemStart", ...) is an app-only Hubitat API; drivers cannot call it. The defensive systemStart belt-and- braces layer in BP14 violated the driver-vs-app API boundary and threw MissingMethodException at every poll tick (~2.75/sec). 162+ errors accumulated in 40 seconds before rollback. Pipeline gap: HubitatSpec base mock had a no-op `mc.subscribe = ...` stub that silently allowed the call in unit tests; tester reported PASS 456/456; production reality differed because the harness didn't model the driver-vs-app API boundary. Revised fix (next commit) will: - Keep schedule()-based polling (the actual fix; schedule() IS persisted across reboots, which is what BP14 needs) - Drop the unnecessary subscribe() + reschedulePoll() systemStart layer entirely - Tighten HubitatSpec mock to fail-fast on driver-vs-app API misuse - Add Bug Pattern #15 (driver-vs-app API distinction) to the catalog Hub rolled back to v27 driver. BP14 victim state (parent 1064 in not-synced after daily reboot) remains until revised fix ships. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces commit 8cd4da6 (reverted in 11eef2c) with a corrected implementation. The original BP14 design used subscribe(location, "systemStart", "reschedulePoll") as a defensive belt-and-braces re-arm -- but subscribe() is an app-only Hubitat API. Drivers crash with MissingMethodException at runtime. Live deploy on the maintainer's hub failed at ~2.75 errors/sec; ops rolled back. Pipeline gap: HubitatSpec mock had a no-op `mc.subscribe` stub that silently allowed the call in unit tests. Tester PASSED 456/456 against a design that crashed in production. Revised design: - schedule("0/N * * * * ?", "updateDevices") in setupPollSchedule() -- schedule() cron jobs ARE persisted across hub reboots per Hubitat platform (the actual BP14 fix). - ensurePollWatchdog() simplified to 3 lines: if scheduleVersion != "2.2", logInfo + setupPollSchedule(). Called from updateDevices() (post-prefs-seed) and sendBypassRequest() (pre-HTTP) -- same call sites as the broken design. - Recursive runIn() at original line 335 still removed. - DROPPED: subscribe(location, ...), reschedulePoll(event), state.systemStartSubscribed flag, unsubscribe() in forceReinitialize() -- all unnecessary now that schedule() alone provides reboot-resilience. Test harness improvements: - HubitatSpec mc.subscribe / mc.unsubscribe REPLACED with fail-fast `{ Object[] args -> throw MissingMethodException(...) }` mimicking Hubitat's actual driver-context behavior. Any future driver code attempting these app-only APIs now fails tests instead of silently passing. - Section I specs reduced 9 -> 6 (I1, I2, I3, I4, I7, I8). I5/I6/I9 deleted alongside the subscribe layer they tested. Bug Pattern #15 added to catalog (CLAUDE.md + qa-agent.md): "Driver code uses app-only Hubitat API (subscribe/unsubscribe to location events)". Symptom: MissingMethodException at runtime; silent test pass via mock. Fix: drivers use schedule() for periodic work; never subscribe() to location events. The HubitatSpec mock for these now throws fail-fast instead of no-op so the harness catches the mistake. CONTRIBUTING.md "High-leverage conventions" table extended with BP14 + BP15 rows. Pipeline discipline (CLAUDE.md Rule 4): added warm-cache iteration clause -- "Within a warm-cache pipeline, iterate without prompting unless an architectural decision, failure, or outbound action is involved." Codifies session-level discipline learned this cycle: over-cautious QA-every-trivial- change patterns cost Opus tokens without catching anything QA hadn't already approved. CHANGELOG.md [Unreleased] -- v2.2 Fixed entry restored with migration-window-scoped known-limitation note (pre-v2.2 idle hub case only; post-migration reboots fully automatic). Test coverage: 453/453 specs PASS. The fail-fast subscribe/ unsubscribe mocks confirmed driver-side absence of those calls (zero invocations during the full test run). Live-verified 2026-04-27 on maintainer hub `dev1064`: round-4 deploy passed Phase 1-4 (deploy, first-poll migration, pre- reboot baseline, reboot recovery). schedule()-based cron auto- resumed post-reboot with NO explicit re-arm log and NO user action -- exactly the BP14 fix proof. Heartbeat returned to `synced` automatically. Zero MissingMethodException for the deleted subscribe/unsubscribe/reschedulePoll APIs (round-1 regression absent). BP14 is FIXED in production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empirical investigation this session (dev + QA self-analyses on the BP14 review cycle) showed CLAUDE.md IS auto-injected on every subagent dispatch but CONTRIBUTING.md is NOT -- it's loaded on-demand via the trigger-phrase table in the "Project documentation files" section. The prior wording in the QA cost-saving levers section claimed both files were auto-injected, an inherited self-analysis error from a prior session. Lazy-loading CONTRIBUTING.md is the correct architectural intent: CLAUDE.md is the small AI-pipeline overlay (always loaded); CONTRIBUTING.md is the larger shared codebase tour (read when relevant). One-line clarification, no behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…port
Two related additions for v2.2: a new preview driver for the Levoit
LV600S Humidifier (LUH-A602S-* family) and EU region support in the
parent driver. Both are EU-relevant -- LV600S has EU model variants
(WEUR/WEU/WJP), and the EU region preference enables the
smartapi.vesync.eu cloud host for non-US users.
== LV600S preview driver (LUH-A602S-*) ==
New child driver `Drivers/Levoit/LevoitLV600S.groovy` (287 lines)
covering 6 model codes (WUSR/WUS/WEUR/WEU/WJP/WUSC) mapped to the
VeSyncHumid200300S class (same as Classic 300S). Warm-mist payload
templated by OasisMist 450S; humidity range 30-80 (vs 450S's
firmware-clamped 40-80). No nightlight (per pyvesync features list).
Critical: pyvesync PR #505 ("fix: set correct auto mode for LV600S")
is OPEN/unmerged at upstream -- disputed firmware divergence between
LUH-A602S-WUS (canonical fixture, mode:"auto") and LUH-A602S-WEU
(PR #505 claim, mode:"humidity"). Driver follows canonical fixture
on the write-path; applyStatus mode read-path emits as-received so
EU firmware variants reporting "humidity" pass through unmapped. 9
inline CROSS-CHECK comment blocks document each contentious decision
against pyvesync sources.
Spec: 327-line `LevoitLV600SSpec.groovy` with 30 test methods (42
invocations counting where-tables) covering BP1/3/6/7/12 patterns,
PR #505 read-path correctness, warm-mist level=0 semantics
(level-derived not warm_enabled-derived), all command payload
shapes, BP12 pref-seed (3-case coverage), and the no-nightlight
absence assertion.
Vendored fixture from upstream pyvesync `LUH-A602S-WUS.yaml`
(commit c98729c) at `tests/fixtures/LUH-A602S.yaml`. SOURCE.md
records the upstream commit hash plus PR #505 disclosure.
Parent-driver wiring: `deviceType()` switch +6 model literals
returning "A602S"; `getDevices()` newList tracking + addChildDevice
branch. RULE22 satisfied via existing LUH- prefix blanket;
TYPENAME_TO_METHOD routing satisfied via "Humidifier" substring
match (no map changes needed). Manifest entry added with
cryptographically random UUID4. Lint config `frozen_driver_names`
extended (RULE19).
Driver carries `version: "2.1"` matching the locked-step package
version; the bump to "2.2" happens for ALL drivers + manifest
atomically at /cut-release time. `[PREVIEW v2.2]` prefix in
description: field (NOT name: -- BP9 protection).
== EU region support (preview) ==
Replaces the static `DEVICE_REGION = "US"` @field constant with a
runtime preference. New `getApiHost()` helper performs binary
US/EU routing per pyvesync const.py: smartapi.vesync.com vs
smartapi.vesync.eu. Three hardcoded URL sites substituted (login,
getDevices, bypassV2). Body field `deviceRegion` (line ~1150) now
reads via `getDeviceRegion()` instead of static constant.
Region-change detection in `updated()` clears state.token +
state.accountID when settings.deviceRegion differs from
state.lastRegion. Pyvesync issues separate tokens per region
(cross-region tokens are invalid). First-ever-save guard
(`state.lastRegion != null`) prevents spurious clears on fresh
installs. Login INFO log mentions region for user verification:
`"Logged in to VeSync (US region: smartapi.vesync.com)"`.
Spec: Section J added to `VeSyncIntegrationSpec.groovy` -- 7 test
methods / 10 invocations: J1 backwards-compat default, J2 EU
preference read, J3 getApiHost() routing 3-row table (US/EU/null),
J4 region-change clear parameterized for both directions
(US-to-EU, EU-to-US), J5 same-region no-op, J6 body field
reflection, J7 first-save guard (null lastRegion).
Documentation: README.md gets a new Configuration section with
EU-users subsection. `Drivers/Levoit/readme.md` extends parent
preferences table with the new region row. CONTRIBUTING.md adds
"EU region support (v2.2 preview)" subsection with a 3-step
validation checklist for EU contributors. CHANGELOG.md and
ROADMAP.md updated.
EU explicitly labeled preview: no live-hardware validation possible;
community testers solicited via the Hubitat thread. The cross-region
token-invalidation assumption is documented but unverified.
== Test coverage + verification ==
- Spock harness: 505/505 invocations PASS (453 baseline +
42 LV600S + 10 EU J-specs)
- Lint: PASS / WARN-only on pre-existing RULE22 (parent driver
regex cases in deviceType()); RULE20 lockstep clean after
driver version field set to "2.1"
- Live deploy on maintainer hub `dev1064`: parent driver loads
cleanly; US-side production unaffected (heartbeat continues
synced, polling + on-command round-trip work, BP14 polling
layer from commit acd54ca intact, zero MissingMethodException
for the deleted/replaced subscribe/unsubscribe APIs from BP14).
LV600S child loads (no LUH-A602S devices on hub for functional
validation -- preview-driver methodology). Region toggle on
production hub deferred (would invalidate user's actual running
US token; too risky on production).
Both ship as preview pending community validation. Validation
reports go to the Hubitat community thread linked from README +
CONTRIBUTING.
Note on file scope: this commit also includes the gh-gotcha
subsection added to CONTRIBUTING.md "Opening a PR" section. That
subsection is logically part of commit 2 (CLAUDE.md slim-down --
GitHub workflow section relocated from CLAUDE.md to CONTRIBUTING.md
as Option C of the slim-down). Bundled here to avoid git add -p
complexity on CONTRIBUTING.md (which has BOTH the EU region
subsection and the gh-gotcha subsection in its diff). Commit 2
references this back.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continues the v2.2 slim-down begun in commit 3e5a0be. CLAUDE.md landed at 21% reduction in that round (target was 30-40%); residual ~9-19% capacity remained per TODO.md:42. This commit takes another ~25% off (446 -> 335 lines), bringing cumulative reduction to ~38% from the v2.0 baseline -- hits the original target. Five edits applied: A. BP14/BP15 detailed entries moved to qa-agent.md only; CLAUDE.md keeps one-liner index entries matching the BP1-13 pattern. The full Symptom/Root-cause/Fix blocks (with code snippets and live-verified footers) already exist in qa-agent.md; removing the duplication saves ~38 lines from CLAUDE.md auto-injection on every cold dispatch. Consistent with the BP1-13 catalog pattern -- CLAUDE.md is the orientation index, qa-agent.md is the operational source-of-truth. C. GitHub workflow section (the gh repo set-default fork-remotes gotcha, ~35 lines covering both remotes, the cryptic GraphQL error users hit, the fix, and the per-command --repo alternative) relocated from CLAUDE.md to CONTRIBUTING.md under "Opening a PR -> Fork remotes (gh CLI gotcha)". Humans need this as much as agents do; CONTRIBUTING.md is the right home. CLAUDE.md keeps a one-line pointer for AI sessions. Note: the CONTRIBUTING.md additions live in commit 4730a52 (paired with the EU region subsection in the same file) to avoid git add -p complexity on a multi-section diff. D. Rule 3 (resume vs fresh dispatch -- cache state observation) compressed from ~31 lines to ~14. The cache TTL prose was tightened; sub-rules a-f folded into a single "Resume protocol" paragraph at the end. The cache-warmth matrix table is preserved verbatim (load-bearing reference; the table itself is the rule). E. Cost optimization notes section slimmed from ~13 lines to ~4. The bullet list of agent-role-cost-asymmetry replicated information already implied by the QA dispatch section's "Cost-saving levers" subsection (Sonnet/Haiku/Opus rates, resume vs fresh, output containment). Slimmed to a tight summary referencing the upstream detail. F. Source references in this codebase -- ~21 lines to ~9. The inline code blocks (envelope-peel while loop, 3-signature update pattern) removed; canonical detail with full code lives in the dev/qa agent defs and was already canonicalized there per the v2.2 slim-down architecture. CLAUDE.md keeps one-line pointers per pattern as the orientation index. Net per-file changes: - CLAUDE.md: 446 -> 335 lines (-25% this round) - CONTRIBUTING.md: gh-gotcha subsection (~25 lines) committed in commit 4730a52 alongside the EU region subsection Cumulative CLAUDE.md reduction from v2.0 baseline: ~38% (hits original 30-40% target). No behavior change; pure editorial. Agents continue to operate correctly because the canonical detail for everything moved already exists in the agent defs (BP catalog), CONTRIBUTING.md (GitHub workflow), or referenced canonical sections (cost notes, source references). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n 450S, Dual 200S, routing breadth
Bundles 12 interlocked v2.2 polish tasks plus a new preview driver,
verified live on maintainer hub (parent 1670, Vital 200S 1847,
Superior 6000S 1848 deployed; clean heartbeat post-deploy; zero
regressions: no MissingMethodException, no "No such property: msg",
no subscribe errors).
NEW BUG PATTERN: BP16 -- debug auto-disable doesn't survive reboot
====================================================================
Symptom: settings.debugOutput stays true forever after a hub reboot,
producing log spam indefinitely. Root cause same shape as BP14:
runIn(1800, "logDebugOff") in updated() is in-memory only and
evaporates across reboot, while settings.debugOutput persists.
Maintainer's parent driver 1064 was found stuck-on for weeks.
Fix: state.debugEnabledAt timestamp captured in updated() when debug
is enabled (cleared when disabled), plus ensureDebugWatchdog() at top
of every poll/command entry that auto-disables when elapsed > 30 min.
Applied across all 17 drivers (parent + 16 children). Existing
runIn(1800,...) preserved for the happy-path; watchdog covers the
post-reboot path. Self-heal occurs within one poll cycle of the
next interaction after a reboot.
RULE25 lint enforces presence of ensureDebugWatchdog() call site in
all Drivers/Levoit/*.groovy.
NEW PREVIEW DRIVER: Levoit Dual 200S Humidifier
====================================================================
LUH-D301S-* family (5 model codes including WEU/KEUR for EU). Mist
range 1-2 only (vs 1-9 on Classic 300S, per pyvesync device_map.py),
no sleep mode (auto/manual only), no warm mist, no setNightLight
command (feature flag absent -- nightLightBrightness emitted as
read-only passive attribute). Multi-firmware mode handling via
try-canonical-then-fallback-with-cache (same pattern as LV600S /
OasisMist 450S below). Fixture vendored from pyvesync Dual200S.yaml
(commit c98729c). Spec: 32 tests covering BP1/3/6/12, mist clamping
(Classic-300S-style 9 clamped to 2), sleep-mode rejection,
multi-firmware fallback, read-path normalization, no-warm-mist /
no-nightlight-command contracts. Manifest UUID
4498037d-0da4-40ad-a587-0a607b76c475.
EXPANDED: OasisMist 450S RGB nightlight (LUH-O451S-WEU)
====================================================================
EU 4.5L variant supports RGB color nightlight (WUSR/WUS variants do
not). Implemented as runtime if-gate on the existing OasisMist 450S
driver -- NOT a separate driver -- to avoid driver proliferation.
Capability "ColorControl" + setNightlightSwitch declared in metadata
(static at compile-time); commands gate at runtime on
state.deviceType == "LUH-O451S-WEU". Non-WEU users see commands but
they no-op with INFO log.
Implementation:
- HSV math via pure functions rgbToHsv / hsvToRgb / applyBrightnessToRgb
- 8-color gradient mapped to colorSliderLocation by nearest-point
Euclidean match (Red/Orange/YG/Green/Cyan/Blue/Purple/Pink anchors
from pyvesync PR #502)
- 180s stale-data gate (state.rgbNightlightSetTime) prevents poll
data from overwriting a just-sent command
- Brightness floor clamped at 40 (firmware constraint)
- state.clear() in updated() resets the gate
Plumbing for runtime variant detection (Task 11): parent's 14
addChildDevice branches now call updateDataValue("deviceType",
device.deviceType) so children can read the model code from
DataValue. OasisMist 450S child self-seeds state.deviceType from
device.getDataValue("deviceType") at top of applyStatus() so
existing v2.1 installs heal on first poll without intervention.
Combined: the gate works on both fresh installs and migrations.
NEW DIAGNOSTIC: probeNightLight() command on OasisMist 450S
====================================================================
One-shot custom command that POSTs setNightLightBrightness with
{night_light_brightness: 50} regardless of variant, logs the inner
response code at INFO level (not gated). Used to capture diagnostic
evidence from EU community testers without requiring debug logs.
EXPANDED: Multi-firmware "auto" vs "humidity" mode handling
====================================================================
Some EU SKUs (LUH-A602S-WEU, LUH-O451S-WEU, LUH-D301S-WEU/-KEUR)
ship firmware where mode:"auto" returns inner code -1 ("invalid
mode") and mode:"humidity" is the canonical auto-mode payload. Both
write-path AND read-path now adapt:
- Write path (sendModeRequest helper): try canonical "auto"; on
rejection retry with "humidity"; cache the discovered variant in
state.firmwareVariant ("std" or "alt") so subsequent setMode("auto")
calls go direct to the correct payload, no retry. Cleared on
updated() so firmware updates get re-detected.
- Read path: applyStatus normalizes incoming mode:"humidity" and
mode:"autoPro" to user-facing "auto" (alt-firmware aliases).
mode:"sleep" passes through unchanged.
Applied to LV600S (in this round; its v2.2-shipping driver from
4730a52 had documented-but-not-implemented fallback), OasisMist 450S
(new), and Dual 200S (new). User-input setMode("humidity") still
rejected as invalid (pyvesync issue #295) -- only the internal
firmware-aware payload routing uses it.
TestParent harness extended with requestResponses queue (sequential
multi-call response programming, backward-compatible with
cannedResponse single-shot). Enables the multi-call rejection-then-
acceptance test patterns that the fallback verifies.
EXPANDED: Routing breadth (Task 6)
====================================================================
Audited all driver families against pyvesync device_map. 6 routing
gaps closed for previously-Generic-fallthrough model codes:
- LUH-O451S-WEU (EU OasisMist 450S 4.5L)
- LAP-V201S-AEUR (EU Vital 200S)
- LAP-V201-AUSR (AU Vital 200S -- intentional missing 'S' in SKU,
documented as 1-footnote in README)
- LAP-C202S-WUSR (US Core 200S variant)
- LAP-V201S-WJP (Japan Vital 200S)
- LAP-C302S-WUSB (US Core 300S bundle SKU)
Two additional codes (LAP-C301S-WAAA, LAP-C302S-WGC) cataloged in
ROADMAP "Deferred model code variants" pending community hardware
reports for region-suffix confirmation.
OBSERVABILITY: parent line 540 + deviceType plumbing
====================================================================
- Parent line 540: resp.msg -> resp?.msg null-safety. Was producing
hourly "No such property: msg" bursts in maintainer's logs on
transient API hiccups (Task 10).
- Parent's 14 addChildDevice branches now call updateDataValue with
deviceType, configModule, cid, uuid so children's variant-specific
logic (RGB gate above) can read it from DataValue (Task 11).
Combined with child self-seed at applyStatus entry, existing
installs heal on first poll without intervention.
LINT INFRASTRUCTURE
====================================================================
- RULE23 (driver_app_only_api): forbids subscribe()/unsubscribe()
in Drivers/Levoit/*.groovy. App-only API; drivers crash with
MissingMethodException at runtime. Closes the gap that let BP15
reach the v2.1 hub.
- RULE24 (agent_pointer_integrity): verifies "see CLAUDE.md X" /
"see CONTRIBUTING.md X" references resolve to actual section
headers. Prevents doc drift between agent defs and pointers.
- RULE25 (bp16_watchdog_call_site): enforces ensureDebugWatchdog()
call site in Drivers/Levoit/*.groovy.
DOCS
====================================================================
- CHANGELOG.md: 6-routing variants, RGB nightlight, Dual 200S,
Dual 200S parent wiring, Dual 200S spec, Dual 200S fixture, BP16
- README.md: device-support matrix updates (Core 200S/300S/Vital
200S/OasisMist 450S WEU/Dual 200S), LAP-V201-AUSR footnote
- ROADMAP.md: RGB nightlight v2.2 entry, Dual 200S + OasisMist 450S
EU struck-through (shipped), runIn-hygiene future-pass note,
deferred-model-codes table
- Drivers/Levoit/readme.md: Dual 200S section, OasisMist 450S RGB
capability matrix, EU model variants, V201-AUSR footnote
- CLAUDE.md: BP16 catalog index entry; "Logging conventions"
heading drops parenthetical (RULE24 self-defeating fix)
- CONTRIBUTING.md: BP16 row added to invariants table
- agents/vesync-driver-qa.md: BP16 catalog entry (~50 lines)
- agents/vesync-driver-operations.md: typo fix
- tests/fixtures/SOURCE.md: LUH-D301S.yaml provenance row
VERIFICATION
====================================================================
- Spock harness: 622/622 PASS (594 baseline + 28 polish-round
additions: BP16 watchdog tests across LV600S/OasisMist 450S/
Core 200S, parent K1+L1-L6 model-routing/watchdog tests,
multi-firmware fallback specs, RGB tests, probeNightLight tests,
read-path normalization tests, Dual 200S spec)
- Static lint: PASS (RULE23/24/25 added, all clean)
- Live ops verify on hub: PASS (parent 1670, Vital 200S 1847,
Superior 6000S 1848 deployed; continuous syncing/synced
heartbeat at 30s intervals; command round-trip on device 1070
OK; zero MissingMethodException, zero "No such property: msg",
zero subscribe errors; LV600S + Dual 200S compile-loaded
without bound devices; driver 1849 zombie cleanup confirmed
absent from hub driver list)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cut v2.2 of the level99/Hubitat-VeSync community fork.
== Substantive changes (already in branch from prior commits) ==
Two new humidifier drivers (LV600S + Dual 200S, both preview), EU
region support (preview), RGB color nightlight on the EU OasisMist
450S 4.5L variant, and routing for 6 additional regional model code
variants. Two reboot-survival bug fixes (BP14 polling cycle, BP16
debug auto-disable). Three new lint rules (RULE23/24/25). New
shared-contributor docs (CONTRIBUTING.md, CODE_OF_CONDUCT.md). See
CHANGELOG.md [2.2] entry for full bullet list.
== This commit (release-cut artifacts only) ==
A. levoitManifest.json
- version: "2.1" -> "2.2"
- dateReleased: "2026-04-26" -> "2026-04-27"
- releaseNotes: prepended new v2.2 line covering user-facing
changes (LV600S + Dual 200S new drivers, EU region preview,
RGB nightlight WEU, 6-routing variants, BP14 + BP16 fixes,
in-place HPM upgrade with no re-pairing). All three lines
scrubbed of redundant "Dan Cox -" author tag (manifest
`author` field at the top already credits the maintainer).
B. CHANGELOG.md
- [Unreleased] - v2.2 -> [2.2] - 2026-04-27
- Added release-summary blurb at the top of the section
- Added 3 bullets for CODE_OF_CONDUCT.md, CONTRIBUTING.md,
and the 3 new lint rules
- Added new "Changed" section with 4 bullets:
parent updateDevices() routing centralized, manifest
releaseNotes cumulation, community thread URL update,
README install-path correction
C.7. Per-driver version: field lockstep
- All 17 drivers' definition() version: field bumped from
"2.1" to "2.2" (lint RULE20 lockstep -- required at every
release cut)
D. ROADMAP.md
- "v2.2 - next release (TBD)" section retired (CHANGELOG is
now system-of-record for shipped v2.2 work)
- "v2.3 - next release (TBD)" section added with full scope:
7 new drivers (OasisMist 1000S US/EU, EverestAir, Sprout
Air, Sprout Humidifier, Classic 200S, LV600S Hub Connect)
promoted from prior "Beyond v2.2" Tier 2/3 tables, plus
10 polish + completion items (Pedestal Fan write-path,
runIn hygiene, routing refactor, opportunistic regional
codes, upstream pyvesync PR roll-up, BP16 live-verified
footer, OasisMist 450S WUSR brightness nightlight,
pyvesync PR #502 fold-in, Notification Tile manifest
decision, Tower Fan displayingType resolution)
- "Beyond v2.3 - unscheduled" reorganized:
* Tier 2/3 tables removed (all active items promoted to v2.3)
* NEW "Coverage gaps identified (v2.2 audit)" section with
4 blocked items: 36-Inch Smart Tower Fan, Core Mini /
Core Mini-P, CirculAir Oscillating Fan, Pet Odor & Hair
Air Purifier (CES 2025 pre-launch). Each blocked on
upstream pyvesync investigation OR community deviceType
capture
* Tier 4 (LV-PUR131S/LV-RH131S V1 API out-of-scope) preserved
* Deferred regional codes (LAP-C301S-WAAA, LAP-C302S-WGC)
preserved
* Tooling & dev experience: removed shipped items
(CONTRIBUTING.md), removed v2.3-promoted item (runIn
hygiene pass)
F. CONTRIBUTING.md drift fixes
- Codebase orientation tree: added LevoitLV600S.groovy and
LevoitDual200S.groovy entries
- Lint rules count: "22 pluggable rules (BP1-13, RULE15-22)"
-> "25 pluggable rules (BP1-16, RULE15-25)"
G. README.md drift fixes (3 prose-only)
- "v2.1 drivers" -> "v2.1+ drivers" in preview-driver
description
- "beyond v2.1" -> "beyond v2.2" in ROADMAP pointer
- "v2.1 cross-check sources" -> "cross-check sources" in
pyvesync acknowledgement (version qualifier dropped since
cross-check sources are used across all releases)
H. repository.json drift fix (folded in via amend)
The HPM tier-2 repository.json `description` field was last
touched at v2.0 cut and silently drifted across both the v2.1
release (5 new drivers) and the v2.2 release (LV600S, Dual
200S, EU region) without ever being updated. Cut-release spec
had no artifact for repository.json drift -- this commit
updates the description to mention v2.2 humidifier additions
and EU region preview; the spec follow-up commit adds a new
drift check so future cuts catch this automatically.
I. Drivers/Levoit/readme.md top-blurb drift fix (folded in via amend)
The opening prose blurb (lines 7-13) listed device additions
per release with `*(v2.1)*` annotations but had no equivalent
block for v2.2. Added LV600S, Dual 200S, EU region preview,
and OasisMist 450S WEU RGB nightlight to the blurb with v2.2
annotations. The driver-list table further down was already
up-to-date from the v2.2 polish round; only the top-blurb
prose was stale.
== Verification ==
- Spock harness: 622/622 PASS (UP-TO-DATE from prior polish-round
run on commit 4dc2c54; no test-affecting code changed since)
- Static lint: PASS (1 pre-existing RULE22 structural advisory
about regex cases in deviceType(); same condition as v2.1 cut,
documented + tolerated)
- Live ops verify on hub: PASS
* Parent 1670 (v33->34), Vital 200S 1847 (v4->5), Superior
6000S 1848 (v4->5) deployed
* Continuous syncing/synced heartbeat at 30s intervals
post-deploy
* Command round-trip on Master Air Purifier 1070 (Vital 200S
child): on/off OK
* Live exercise on Downstairs Humidifier 1121 (Superior 6000S):
on / setMode(manual) / setMode(auto) / off all clean.
setMistLevel(5) and setTargetHumidity(45) returned VeSync
API errors during a wick-drying maintenance cycle (hardware-
driven, NOT a driver regression -- driver correctly
propagated the error response). Original state (off, manual,
mist=2, target=60) restored after exercise.
* Zero MissingMethodException, zero "No such property: msg",
zero subscribe(location, ...) errors
* Driver 1849 zombie cleanup confirmed absent
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ecks) Three corrections to the /cut-release slash command spec, all surfaced during the v2.2 cut. 1. Drop <author> slot from releaseNotes line-format template. The spec previously instructed releaseNotes lines as: <version> - <author> - <user-facing description>. This was redundant: the manifest's top-level `author` field already credits the maintainer; repeating it inline on every release line is noise. The v2.2 cut scrubbed `Dan Cox -` from all three historical releaseNotes lines (v2.0, v2.1, v2.2) for consistency. Spec template + example block updated to match. 2. Add Artifact G -- README.md drift check. Catches stale prose-version references in README.md that drift release-over-release: "Preview drivers are v<X.Y> drivers..." generalizing prose, "beyond v<X.Y>" pointer, "v<X.Y> cross-check sources" acknowledgement qualifier. Explicit non-target: per- device row annotations like *(v2.1 preview)* are intentional historical markers and must not be touched. Surface drift as proposed edits, never apply automatically. 3. Add Artifact H -- repository.json drift check. The HPM tier-2 repository.json `description` field drifted silently across v2.0 -> v2.1 -> v2.2 because no artifact checked it. v2.0 cut described the v2.0 device set; v2.1 added 5 drivers without updating the description; v2.2 added 2 more + EU region without updating either. v2.2 cut amended the cut commit with the corrected description. Going forward, this artifact catches the drift at cut time -- compares description against the current driver-file inventory + recent CHANGELOG entries. 4. Add Artifact I -- Drivers/Levoit/readme.md top-blurb drift check. The opening prose blurb listed device additions per release with *(v2.1)* annotations but had no equivalent block for v2.2. v2.2 cut amended the cut commit with the corrected blurb (LV600S, Dual 200S, EU region, OasisMist 450S WEU RGB nightlight added with v2.2 annotations). Going forward, this artifact catches the drift -- scans the top-blurb prose, compares against the driver- file inventory + version being cut. Doesn't touch existing rows (intentional historical markers); doesn't touch the per-driver table further down (developer-agent responsibility on each driver-add diff). Plus matching insertions in: - Step 5 output template (Artifacts G/H/I entries before the approval prompt) - Step 6 application list (apply Artifacts G/H/I edits if approved) - Step 7 final-report staged-ready file list (README.md, repository.json, Drivers/Levoit/readme.md added) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five NIT-tier findings from the final pre-PR QA review of the v2.2
release diff (run on Opus). All applied surgically; tester PASS
622/622 with zero regressions.
NIT 1 -- Parent: backfill deviceType data value for existing children
====================================================================
v2.2's runtime feature gates (RGB nightlight on LUH-O451S-WEU; future
v2.X driver-variant features) read state.deviceType, which children
self-seed from device.getDataValue("deviceType"). Fresh installs on
v2.2 set the data value correctly via parent's addChildDevice path,
but existing v2.1 installs upgrading in place took the existing-child
else branch which only refreshed name/label -- never set the
deviceType data value. Result: state.deviceType self-seeds as "" and
the WEU RGB gate silently no-ops for upgraded users.
Fix: every existing-child else branch in VeSyncIntegration.groovy's
getDevices() (15 branches across all dtype handlers) now also calls
equip1.updateDataValue("deviceType", device.deviceType). Backfill is
idempotent and benefits any future v2.X feature that depends on
state.deviceType, not just OasisMist 450S WEU RGB.
NIT 2 -- Dual 200S: remove dead humHigh/humHighBool parse code
====================================================================
LevoitDual200S.groovy lines 457-459 parsed r.humidity_high into
humHighBool then never emitted. Comment block at lines 454-456
correctly explained the design decision (humidityHigh not exposed)
but the parse code below it was orphaned. Removed the dead lines;
preserved + tightened the comment block to be self-contained.
NIT 3 -- BP16 watchdog moved to top of applyStatus()
====================================================================
ensureDebugWatchdog() was called only from update(status, nightLight)
(2-arg parent-poll path). The 0-arg update() (user-refresh) and
1-arg update(status) (Core 200S Light) paths bypassed it. Practical
impact was minimal because the 2-arg cron fires every 30s, but it's
a code-smell -- all update entry points should converge on the same
diagnostic path.
Fix: moved ensureDebugWatchdog() to the top of applyStatus() in 10
drivers (Dual 200S, LV600S, OasisMist 450S, Classic 300S, Superior
6000S, Vital 100S, Vital 200S, Tower Fan, Pedestal Fan, Generic).
Placed after logDebug "applyStatus()" entry log, before the BP12
pref-seed block. Since all three update() variants funnel through
applyStatus() (or its equivalent in the V2 lineage), one call site
covers everything.
Core line (200S/300S/400S/600S/Light) intentionally NOT changed:
they have no separate applyStatus() -- update(status, nightLight) is
itself the terminal handler, so the watchdog placement in the 2-arg
signature already covers the primary poll path with no structural
improvement available.
RULE25 lint still passes (checks presence, not specific location).
NIT 4 -- Remove redundant runIn(10, "updateDevices") post-discovery
====================================================================
VeSyncIntegration.groovy's getDevices() previously fired a
runIn(10, "updateDevices") after discovery completes -- a relic from
the pre-BP14 era when polling was runIn-chain-based and needed an
explicit kick-off. With BP14's schedule()-based cron now armed in
setupPollSchedule() (called from initialize()), the runIn produces
a duplicate heartbeat-syncing/synced event pair ~10s apart on first
install. Cosmetic only, but removed for cleanliness.
The runIn(5 * (int)settings.refreshInterval, "timeOutLevoit") above
it is preserved -- that's load-bearing (timeout watchdog).
NIT 5 -- OasisMist 450S: comment-only annotation on probeNightLight
====================================================================
The probeNightLight response handler intentionally uses direct
log.info (not the gated logInfo helper) so output is visible
regardless of descriptionTextEnable -- diagnostic intent. Added one
sentence to the existing intent comment block explaining that
direct log.X is acceptable in child drivers because they don't
handle accountID/token, so bypassing the parent's sanitize() chain
carries no credential-exposure risk.
Verification
====================================================================
- Spock harness: 622/622 PASS
- Static lint: PASS (1 pre-existing RULE22 structural advisory --
same condition as v2.1 cut, documented + tolerated)
- No new tests required (NIT 1's backfill is observable via the
L1-L6 BP16 specs in VeSyncIntegrationSpec; NIT 3's relocation is
observable via existing applyStatus tests; NITs 2/4/5 are
behavior-neutral cleanup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… -> tag The /cut-release spec's Step 7 "Next steps for you" block instructed the maintainer to tag immediately after committing the cut, then push branch+tag together. That's wrong order. The actual flow used for v2.0 and v2.1 (and now v2.2) is: cut commit -> push branch -> open PR -> iterate on review -> squash-merge -> tag the squash commit on main -> push tag -> GitHub Release. Per TODO.md history: "v2.1 squash-merged @ 058716d -> tagged v2.1 -> GitHub Release published" -- tag was on the squash commit, not the release branch tip. Also: the "Notes" section line conflated push/PR/tag/release as "post-merge steps" -- which is misleading because push and PR are post-cut but pre-merge, while tag and Release are genuinely post-merge. That conflation tripped up an in-progress v2.2 cut when the orchestrator inferred /cut-release ran after PR merge instead of before push. Fix: - Step 7 next-steps replaced with the correct 9-step sequence (commit, push branch, open PR, iterate, squash-merge, checkout main, tag squash commit, push tag, GitHub Release, community thread). Tagging explicitly checks out main first so the tag lands on the post-squash commit -- which is what HPM resolves against (HPM URLs in levoitManifest.json point to main). - Notes section split push/PR (POST-CUT, PRE-MERGE) from tag/Release (POST-MERGE) and explicitly says tag the squash commit on main, NOT the release branch tip. CONTRIBUTING.md line 367 ("After merge: the maintainer tags releases") is sparse but not wrong; left alone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary of ChangesHello, 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! Version 2.2 focuses on expanding regional and device support while hardening the driver architecture against platform-specific reboot behaviors. The release introduces preview support for new humidifier models and EU region routing, alongside critical architectural fixes that ensure polling and logging remain stable across hub reboots. Infrastructure improvements include new linting rules and standardized contributor documentation to support ongoing development. Highlights
Ignored Files
Using Gemini Code AssistThe 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
Customization To customize 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 Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. 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
|
There was a problem hiding this comment.
Code Review
This pull request updates the Hubitat-VeSync driver pack to version 2.2, adding support for Levoit LV600S and Dual 200S humidifiers, EU region API routing, and RGB nightlight control for the EU OasisMist 450S. It addresses reboot-survival issues by migrating the poll cycle to persistent cron schedules and implementing a debug-logging watchdog. The PR also introduces a Code of Conduct, contributor onboarding documentation, and new linting rules. Feedback identifies a critical issue in the parent driver's cron expression logic that would cause polling to fail for intervals of 60 seconds or greater, which are recommended in the documentation.
| private setupPollSchedule() { | ||
| Integer interval = Math.max(1, (settings?.refreshInterval ?: 30) as Integer) | ||
| unschedule("updateDevices") | ||
| schedule("0/${interval} * * * * ?", "updateDevices") |
There was a problem hiding this comment.
The Quartz cron expression 0/${interval} * * * * ? is invalid when interval is 60 or greater (e.g., 60, 120), as the increment must be less than the range of the field (0-59). Since the README suggests intervals of 60-120s, this will cause polling to fail to schedule for many users. Consider branching the cron generation logic to use the minutes field for intervals of 60 seconds or more.
String cron = (interval < 60) ? "0/${interval} * * * * ?" : "0 */${(int)(interval / 60)} * * * ?"
schedule(cron, "updateDevices")
…review)
Gemini Code Assist flagged a HIGH-priority issue on the v2.2 release
PR: the BP14 fix's Quartz cron expression `0/${interval} * * * * ?`
is invalid for `interval >= 60` -- the seconds-field increment must
be less than 60. Users on README-recommended 60/120/300s intervals
would have polling fail to schedule entirely; BP14 in v2.2 as-shipped
was broken for the configurations the README told users to use.
Spock harness passed and live-hub verification passed during the
v2.2 cut because no test or runtime path exercised intervals >= 60s
-- effectively documenting the bug rather than catching it.
== Fix ==
`setupPollSchedule()` now branches by interval magnitude:
- `interval < 60` -> seconds-resolution form (existing form)
- `interval >= 60` -> minutes-resolution form: every N minutes in
the minutes field, where N = floor(interval / 60).
Non-multiples of 60 (e.g. 90s) emit a `logWarn` so the user can
correct to a clean multiple. Recommended values: 30, 60, 120, 300.
== Spock coverage ==
Added 5 new tests in VeSyncIntegrationSpec.groovy Section I:
- I1 corrected (was asserting the broken `0/60` form; now asserts
the 30s baseline)
- I5 (60s boundary -> minutes-form `0 */1 * * * ?`)
- I6 (120s -> `0 */2 * * * ?`)
- I9 (300s -> `0 */5 * * * ?`)
- I10 (59s edge -> last valid seconds-form)
- I11 (90s non-multiple -> minutes-form + WARN log assertion)
Total spec count: 627 (was 622). All PASS.
== Secondary fixes folded in (per QA round-2 review) ==
1. Added `logWarn(msg) { log.warn sanitize(msg) }` parent-driver
helper. The new threshold-branch warn for non-multiple intervals
uses logWarn instead of direct log.warn -- routes through the
parent's PII-redaction sanitize() chain, matching the existing
logInfo/logDebug/logError discipline.
2. Fixed RULE23 (driver_app_only_api) lint rule: the existing
`//`-prefix line-skip guard didn't catch `*`-prefixed Javadoc
continuation lines, leading to a false positive on the
`* NOTE: subscribe(location, ...)` Javadoc block. Root cause was
`groovy_lite.strip_block_comments` prematurely closing the
Javadoc block at the `*/` literal inside the cron string -- after
the close, the continuation lines were treated as live code and
RULE23 saw the literal `subscribe(`. Fix expands the skip guard
to also catch `startswith('*')`. No legitimate Groovy driver
pattern leads a code line with `*`.
3. Updated qa-agent.md BP14 catalog entry: code snippet now shows
the threshold-branching form and `logWarn` (not `log.warn`), so
future developers copying the canonical BP14 pattern from docs
get the correct sanitize-routed form.
== Compile-error subtlety (worth documenting) ==
Initial threshold-branch implementation embedded the literal cron
strings `"0 */${minutes} * * * ?"` in the setupPollSchedule()
Javadoc block as documentation. Groovy's block-comment terminator
is `*/` regardless of context, so the `*/` inside the cron literal
closed the Javadoc prematurely -- exactly the same bug class as
v2.1 commit `fe4d723` (the LUH-O451S-*/LUH-O601S-* model-code
Javadoc terminator issue). Spock harness wouldn't compile.
Fix: rewrote the Javadoc cron-syntax explanation block in prose
only, no literal cron strings. The actual cron strings remain in
the method body (lines 324, 332) where the parser handles them
correctly as string literals. Pointer to "method body below" added
in the Javadoc for readers who want the literal forms.
A v2.3 follow-up to add a static lint rule catching `*/` inside
`/** ... */` blocks is queued in TODO.md (this is the SECOND time
the same bug pattern bit us; a static rule eliminates the class).
== Verification ==
- Static lint: PASS (RULE22 pre-existing structural advisory only)
- Spock harness: 627/627 PASS
- QA review (Sonnet, round 2): APPROVE post-fix
- (Live ops verification skipped this round -- pure code-and-spec
fix, no architectural surface change beyond what the v2.2 cut
already validated; can re-run before tag if maintainer wants
paranoia coverage)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v2.3 polish entry for issue #3 ("Refactor updateDevices() routing") treated all three candidate approaches as still-open. v2.2 actually implemented option (a) -- centralized routing into the TYPENAME_TO_METHOD @field map (commit c31c14c). Single source of truth achieved, but the underlying substring coupling (typeName.contains(...)) remains inside the lookup closure. Issue #3 acceptance criterion #3 ("No typeName.contains(...) calls remain in updateDevices()") is NOT met -- full closure requires option (b) deviceType-based routing OR option (c) per-child state.deviceFamily constant. Both queued for v2.3 polish round. Updated ROADMAP entry to reflect v2.2's partial progress and narrow the v2.3 scope to options (b) or (c) only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks for catching this — the cron-syntax issue would have broken polling for users on the README-recommended 60/120/300s intervals. Fixed in 94a8ce5 with the threshold-branching approach you suggested:
Plus 5 new Spock tests in Two secondary fixes folded into the same commit:
Spock 627/627 PASS, lint clean (the pre-existing RULE22 structural advisory only). |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces significant architectural improvements and new device support for the Hubitat-VeSync codebase. Key changes include the addition of drivers for the Levoit LV600S and Dual 200S humidifiers, the implementation of EU region support, and critical fixes for reboot-survival bugs (Bug Patterns #14 and #16) by replacing recursive runIn() poll chains with persistent schedule() cron jobs and adding a debug watchdog. Additionally, the PR adds comprehensive documentation updates, including a new Code of Conduct, updated contributing guidelines, and improved release automation procedures. I have no feedback to provide as all changes are well-documented and address explicitly identified issues.
…manifest exemption documented Two unrelated small polish items bundled per workflow batching. == RULE26: Javadoc */ terminator detector == New static lint rule (`tests/lint_rules/groovy_javadoc_terminator.py`) that detects unescaped `*/` literals embedded inside `/** ... */` Javadoc blocks. Groovy parses `*/` as the end-of-block-comment marker regardless of context -- if a Javadoc block contains a literal `*/` (typically inside example code or model-code list strings), the parser closes the comment at that point, leaving the rest of the intended Javadoc as apparent live code -> compile error. This bug class bit the codebase TWICE before the rule existed: - v2.1 commit fe4d723: `LUH-O451S-*/LUH-O601S-*` model-code list inside a Javadoc broke Spock compilation - v2.2 PR #4 BP14 cron-fix Gemini round: `"0 */${minutes} * * * ?"` cron-string example inside `setupPollSchedule()`'s Javadoc broke Spock compilation Both required tester-fail -> dev-diagnose -> fix iteration. RULE26 catches the pattern at lint time without human eyeball needed. Algorithm: scans every `/**` block in driver source. For each `*/` occurrence, classifies based on the line-content shape -- if the line matches the regex `^\s*\*?\s*\*/$` (whitespace + optional `*` + `*/` + end-of-line), it's a legitimate close. Otherwise, the line has substantive content before the `*/`, meaning the `*/` is embedded -- emit finding, continue scanning. Stops at the first legitimate close. Special cases for empty Javadoc forms (`/**/` and `/***/`) are explicitly handled with documented intent rather than relying on implementation coincidence. Test coverage: 9 cases in `TestRule26JavadocTerminator` covering the v2.1/v2.2 reproducers, nested-block detection, clean-javadoc pass, empty-Javadoc pass, line-comment-with-`*/`-outside-Javadoc pass, string-literal-with-`*/`-outside-Javadoc pass, and the non-groovy-file path-filter check (the last test is currently limited by a `make_fake_path()` helper quirk that appends `.groovy` to any non-groovy/md/json filename -- a pre-existing test infra pattern unrelated to RULE26 logic; production behavior is correct). Run on existing repo: zero RULE26 findings (both prior occurrences fixed before v2.3). == Notification Tile manifest exemption documented == `Drivers/Levoit/Notification Tile.groovy` is intentionally absent from `levoitManifest.json` because it's a generic dashboard-tile companion utility (inherited from upstream thebearmay/hubitat) that users instantiate manually as a virtual device. HPM "Install" of the package wouldn't give the user a working tile device -- the driver alone doesn't auto-create one -- so a manifest entry would be misleading rather than helpful. This was implicitly documented via a `manifest_excluded_files` exemption in `tests/lint_config.yaml`. v2.3 makes the rationale explicit: - `tests/lint_config.yaml`: 4-line comment block above the exemption entry explaining why this is an intentional exception - `CONTRIBUTING.md`: new `### Note on Notification Tile.groovy` subsection between the codebase-tree and architecture paragraph, explaining the omission, pointing to the lint exemption, and saying explicitly "Do not add a manifest entry for this driver" to prevent future bad-fix attempts == Verification == - Spock: 659/659 PASS (no driver code change) - Static lint: PASS (1 pre-existing RULE22 advisory only; zero RULE26 findings on existing repo) - pytest TestRule26JavadocTerminator: 8/9 PASS -- the 1 failing test is a `make_fake_path()` helper limitation (auto-appends `.groovy` extension), not a RULE26 logic bug; production lint scans `.groovy` files only and correctly skips non-groovy files Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(claude.md): workflow optimizations from v2.2 / v2.2.1 retrospective
Add a "Workflow optimizations (lessons learned)" section to CLAUDE.md
codifying five rules that emerged from the v2.2 / v2.2.1 release cycles
where the orchestrator drifted from existing pipeline rules and burned
user time on rework. Plus one matching addition to /cut-release.
== CLAUDE.md additions ==
1. TMI rules for outbound text (commit messages, release notes, PR
bodies, community posts). Concrete drop-list: maintainer-hub
references, pipeline-process detail, implementation jargon, HPM
upgrade boilerplate, hardware-specific test details. Self-check
before showing draft for approval.
2. TaskCreate discipline for multi-fix releases (>2 distinct items
or 2+ pipeline rounds). Prevents re-narrating state at every
handoff.
3. Batch fix-rounds + audit-first when triaging. When user flags
one bug in an actively-reviewed area, spawn audit agent BEFORE
dev to find related issues. v2.2.1 cycle dispatched dev 4 times
that could've been 2.
4. Pre-flight before any tester dispatch: confirm QA APPROVE'd the
exact current diff state, or it's a standalone sanity check.
Drift pattern observed in v2.2.1 (orchestrator went dev -> tester
directly twice, user caught both times, had to backfill QA).
5. HPM stale-state recovery (maintainer-only). Documents the
Repair -> forceReinitialize sequence after MCP-deploy of
release-candidate code to the maintainer's hub. Observed in
v2.2.1 install where HPM tracking fell behind reality.
== cut-release.md addition ==
Step 1 pre-flight gains a production-log audit check. Run on the
maintainer's hub: scan ERROR-level logs since last release tag,
triage anything new BEFORE the cut. v2.2.1 cycle would have caught
BP1 / resp.msg / cron-syntax in one pass via this check; without
it they surfaced one-by-one across 4-5 user-flag rounds.
== Out of scope ==
- README correction for misleading Core ChildLock claim deferred:
v2.3 will add the feature, making the README accurate again. No
point reverting to "no claim" only to re-add in v2.3.
- The CLAUDE.md additions are doc-only; no test impact, no manifest
bump (v2.3 cut handles version bumps).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v2.3): Core line feature back-fill -- childLock + display + timer + resetFilter + AQ symmetry
Closes long-standing feature gaps inherited from the original
NiklasGustafsson upstream where the Core line air purifier drivers
(200S/300S/400S/600S) exposed less than the underlying VeSync API
supports. All new features use JSON-RPC paths already proven by
the Vital line drivers; zero new patterns introduced.
== New attributes/commands ==
All 4 Core drivers (200S/300S/400S/600S):
- attribute "childLock" + command "setChildLock" -- labeled
"Display Lock" in the VeSync mobile app for Core line; exposed
here as childLock for cross-driver consistency with the Vital
line. Per-driver readme.md sections include a terminology bridge
note.
- attribute "display" -- driver previously had write-only setDisplay;
now also reads it back from poll responses
- command "setTimer" + command "cancelTimer" + attribute
"timerRemain" -- auto-off timer support. Uses the V1 Bypass
timer API (addTimer/delTimer) per Core line API class; Vital
line uses V2 timer API (addTimerV2/delTimerV2) -- intentional
divergence per pyvesync class hierarchy. Core takes seconds,
Vital takes minutes -- documented in readme.
- command "resetFilter" -- zero out filter-life percentage after
replacing the filter, without needing the Levoit mobile app
Core 300S/400S/600S only (Core 200S has no AQ sensor):
- attribute "pm25" -- raw PM2.5 in µg/m³ (cast as Integer);
previously buried in the info HTML attribute only
- attribute "airQualityIndex" -- Levoit's own categorical 1-4
reading, distinct from the driver-computed US-AQI 'aqi' attribute
- capability "AirQuality" declaration -- wires the device into
Hubitat's standard air-quality dashboard tiles
== Defensive null-guard fix ==
Wrapped updateAQIandFilter() call in if (status.result?.air_quality_value
!= null) guard on Core 300S/400S/600S. Previously unconditional;
would throw NPE if the field was absent in a particular firmware
state or fixture scenario. The conditional pm25/airQualityIndex
emit block above the call already used the guard; the unconditional
call below was a regression risk for any null-air_quality_value
poll response.
== Test coverage ==
- Spock: 659/659 PASS (633 baseline + 26 new specs in Section M
of each Core driver spec). Core 200S Spec extended +5; Core 300S
Spec extended +7; new Core 400S Spec and Core 600S Spec files
with 7 new specs each. Per-driver coverage: setChildLock payload
shape, parse round-trip, setTimer JSON-RPC, resetFilter, plus
pm25 and airQualityIndex parsing for the AQ-sensor variants.
- Static lint: PASS (1 pre-existing RULE22 structural advisory)
- Live ops verify on hub: setChildLock("on")/setChildLock("off")
round-trip CONFIRMED on real Core 200S hardware (Willow Noise
device, id 1123). Audit's open question about whether Core 200S
firmware accepts setChildLock JSON-RPC: answered yes. childLock
attribute polled correctly post-round-trip; driver INFO log fires
with the "Child lock (Display Lock):" terminology bridge.
== Documentation ==
- Drivers/Levoit/readme.md: Core 200S/300S/400S/600S sections
updated with new event/command tables. Each Core variant gets
a "Display Lock" terminology bridge note (one-liner explaining
the VeSync mobile app calls this "Display Lock" but Hubitat
exposes as childLock for cross-driver consistency). Each section
also gets a "Timer units" note clarifying the seconds vs minutes
divergence between Core line and Vital line.
- CHANGELOG.md: new [Unreleased] -- v2.3 section with full Added
bullet describing all features above.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(v2.3): polish bundle -- runIn hygiene + routing refactor (#3) + 2 regional codes
Three improvements bundled per workflow batching: low-risk
mechanical cleanup, an architecture refactor that closes
GitHub issue #3, and two new regional model codes.
== runIn hygiene ==
Converted 14 bare-identifier runIn(N, handlerName) calls to
string-literal runIn(N, "handlerName") form across 8 driver
files (Core 200S/300S/400S/600S, Core 200S Light, Vital 200S,
Superior 6000S, Notification Tile). Bare-identifier form
depends on Hubitat sandbox binding that doesn't replicate in
the Spock test classloader; string-literal works in both.
Triaged every runIn / runInMillis call site by reboot-survival
risk. No tier-(c) load-bearing-vulnerable timers found beyond
what BP14 (poll cycle) and BP16 (debug auto-disable) watchdogs
already cover. Tier-(a) one-shot user-action timers (post-Save
runIn(3, "update"), post-discovery runIn(15, "initialize")) are
acceptable as-is -- the next schedule()-driven poll covers any
reboot-window losses via ensurePollWatchdog().
== Routing refactor (closes issue #3 AC #3) ==
VeSyncIntegration.groovy's updateDevices() previously routed
per-device API methods via TYPENAME_TO_METHOD @field map with
typeName.contains() substring match -- the v2.2 partial fix.
That fails issue #3's "no typeName.contains in updateDevices()"
acceptance criterion.
Replaced with new private deviceMethodFor(child) helper that
reads child.getDataValue("deviceType"), maps via the existing
deviceType() switch to a dtype, then switches on dtype to API
method. Removes TYPENAME_TO_METHOD @field and DEFAULT_POLL_METHOD;
no typeName substring matching remains.
Behavior preservation verified: all 16 driver families route
to identical API method strings as before:
- TOWERFAN -> getTowerFanStatus
- PEDESTALFAN -> getFanStatus
- 5 humidifier dtypes -> getHumidifierStatus
- 6 purifier dtypes + GENERIC -> getPurifierStatus
The deviceType DataValue plumbing the v2.2.1 hotfix added (Bug 1's
addChildDevice updateDataValue calls) is now load-bearing for this
routing path -- existing children migrated correctly via the BP2
specs.
BP2 lint rule rewritten as positive assertion (deviceMethodFor
must be present in updateDevices()) replacing the old "search
for getPurifierStatus literal" heuristic. Fix-text guidance
updated to point to deviceMethodFor() pattern, not the
deprecated typeName.contains() approach.
Section H Spock specs (H1-H6) updated to set updateDataValue
before exercising the routing -- they previously relied on the
typeName substring match. Pre-existing BP2 specs corrected for
the same reason.
== Regional codes ==
- LAP-C401S-KUSR -- Core 400S PlasmaPro black variant. Added
to deviceType() switch. Same VeSyncAirBypass class as the
other Core 400S variants; routes to existing driver.
- LPF-R432S-AUK -- UK Pedestal Fan. Added to deviceType() switch
and to LevoitPedestalFan.groovy header / description field.
== Verification ==
- Spock: 659/659 PASS (run by dev; QA validated)
- Static lint: PASS (1 pre-existing RULE22 advisory only)
- Live ops verify on hub: PASS. Parent driver deployed; all 5
in-use children (Vital 200S, Superior 6000S x2, Core 200S x2)
continue polling correctly post-deploy. Heartbeat synced.
Child attributes (airQualityIndex, pm25, humidity, mistLevel,
childLock) all current. No new MissingMethodException /
MissingPropertyException / routing errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(v2.3): RULE26 Javadoc terminator lint rule + Notification Tile manifest exemption documented
Two unrelated small polish items bundled per workflow batching.
== RULE26: Javadoc */ terminator detector ==
New static lint rule (`tests/lint_rules/groovy_javadoc_terminator.py`)
that detects unescaped `*/` literals embedded inside `/** ... */`
Javadoc blocks. Groovy parses `*/` as the end-of-block-comment
marker regardless of context -- if a Javadoc block contains a
literal `*/` (typically inside example code or model-code list
strings), the parser closes the comment at that point, leaving
the rest of the intended Javadoc as apparent live code -> compile
error.
This bug class bit the codebase TWICE before the rule existed:
- v2.1 commit fe4d723: `LUH-O451S-*/LUH-O601S-*` model-code list
inside a Javadoc broke Spock compilation
- v2.2 PR #4 BP14 cron-fix Gemini round: `"0 */${minutes} * * * ?"`
cron-string example inside `setupPollSchedule()`'s Javadoc broke
Spock compilation
Both required tester-fail -> dev-diagnose -> fix iteration. RULE26
catches the pattern at lint time without human eyeball needed.
Algorithm: scans every `/**` block in driver source. For each `*/`
occurrence, classifies based on the line-content shape -- if the
line matches the regex `^\s*\*?\s*\*/$` (whitespace + optional `*`
+ `*/` + end-of-line), it's a legitimate close. Otherwise, the
line has substantive content before the `*/`, meaning the `*/` is
embedded -- emit finding, continue scanning. Stops at the first
legitimate close.
Special cases for empty Javadoc forms (`/**/` and `/***/`) are
explicitly handled with documented intent rather than relying on
implementation coincidence.
Test coverage: 9 cases in `TestRule26JavadocTerminator` covering
the v2.1/v2.2 reproducers, nested-block detection, clean-javadoc
pass, empty-Javadoc pass, line-comment-with-`*/`-outside-Javadoc
pass, string-literal-with-`*/`-outside-Javadoc pass, and the
non-groovy-file path-filter check (the last test is currently
limited by a `make_fake_path()` helper quirk that appends `.groovy`
to any non-groovy/md/json filename -- a pre-existing test infra
pattern unrelated to RULE26 logic; production behavior is correct).
Run on existing repo: zero RULE26 findings (both prior occurrences
fixed before v2.3).
== Notification Tile manifest exemption documented ==
`Drivers/Levoit/Notification Tile.groovy` is intentionally absent
from `levoitManifest.json` because it's a generic dashboard-tile
companion utility (inherited from upstream thebearmay/hubitat) that
users instantiate manually as a virtual device. HPM "Install" of
the package wouldn't give the user a working tile device -- the
driver alone doesn't auto-create one -- so a manifest entry would
be misleading rather than helpful.
This was implicitly documented via a `manifest_excluded_files`
exemption in `tests/lint_config.yaml`. v2.3 makes the rationale
explicit:
- `tests/lint_config.yaml`: 4-line comment block above the exemption
entry explaining why this is an intentional exception
- `CONTRIBUTING.md`: new `### Note on Notification Tile.groovy`
subsection between the codebase-tree and architecture paragraph,
explaining the omission, pointing to the lint exemption, and
saying explicitly "Do not add a manifest entry for this driver"
to prevent future bad-fix attempts
== Verification ==
- Spock: 659/659 PASS (no driver code change)
- Static lint: PASS (1 pre-existing RULE22 advisory only; zero
RULE26 findings on existing repo)
- pytest TestRule26JavadocTerminator: 8/9 PASS -- the 1 failing
test is a `make_fake_path()` helper limitation (auto-appends
`.groovy` extension), not a RULE26 logic bug; production lint
scans `.groovy` files only and correctly skips non-groovy files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v2.3): new humidifier drivers -- Classic 200S + LV600S Hub Connect
Both new drivers; preview-driver pattern (no hardware available).
- Classic 200S (model code Classic200S): pyvesync VeSyncHumid200S class.
setIndicatorLightSwitch for display, {mode} payload, nested snake_case
auto_target_humidity. CROSS-CHECK note vs Classic 300S (different class,
naming trap).
- LV600S Hub Connect (LUH-A603S-WUS): pyvesync VeSyncLV600S class.
workMode:'humidity' on wire (normalized to 'auto' in events), setLevel
for warm mist, camelCase top-level targetHumidity. CROSS-CHECK note vs
existing LV600S A602S driver (different class, different SKU).
Per-driver: driver + Spock spec + pyvesync fixture + parent deviceType()
+ addChildDevice wiring + frozen_driver_names + readme + manifest entry.
80 new specs (659 -> 739).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v2.3): new humidifier driver -- OasisMist 1000S (US + EU)
Single driver routing 3 regional codes (LUH-M101S-WUS, -WUSR, -WEUR).
Preview-driver pattern; no hardware available.
- pyvesync VeSyncHumid1000S class. Distinct payload conventions vs prior
humidifier classes: setSwitch {powerSwitch, switchIdx}, setHumidityMode
{workMode}, mist API method "virtualLevel" (NOT "setVirtualLevel"),
setTargetHumidity {targetHumidity} top-level camelCase, setDisplay
{screenSwitch:int}, setAutoStopSwitch (NOT setAutomaticStop).
- Nightlight WEUR-only: setNightlight(state, brightness=null) splits
to setNightLightStatus (toggle) vs setLightStatus (brightness+toggle)
matching pyvesync's two distinct API endpoints. Runtime-gated via
isNightlightVariant() reading state.deviceType; US variants no-op.
- CROSS-CHECK comments distinguishing 1000S from VeSyncHumid200S /
VeSyncHumid200300S / VeSyncLV600S (4 sibling humidifier classes).
Per-driver: driver + 44-test Spock spec (BP1/3/6/12) + pyvesync fixture
+ parent deviceType x3 codes + addChildDevice wiring + frozen_driver_names
+ readme + manifest entry. 44 new specs (739 -> 783).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v2.3): new Sprout family drivers -- Humidifier + Air Purifier
Both preview drivers; no hardware available.
- Sprout Humidifier (LEH-B381S-WUS, -WEU): pyvesync VeSyncSproutHumid.
Auto mode wire value "autoPro" (normalized to "auto" on read), mist
setVirtualLevel API method with levelIdx/levelType payload, range 1-2,
TemperatureMeasurement capability with F*10 division. Nightlight has
Kelvin colorTemperature (2000-3500K) -- unique three-parameter control
via setLightStatus. Drying-mode + auto-stop commands.
- Sprout Air Purifier (LAP-B851S-WUS/-WEU/-WCA/-AEUR/-AUS, LAP-BAY-MAX01S):
pyvesync VeSyncAirSprout. Manual mode dispatched via setLevel
{manualSpeedLevel, levelType:"wind"} (NOT setPurifierMode), distinct
from Core/Vital lines. Nightlight setNightLight {night_light: enum}
(on/off/dim string). AirQuality + RelativeHumidityMeasurement +
TemperatureMeasurement capabilities; fanSpeedLevel 255 -> 0 (BP6 analog).
- CROSS-CHECK comments distinguishing each from sibling pyvesync classes.
Per-driver: driver + Spock spec + pyvesync fixture + parent deviceType
(2 humidifier + 6 purifier codes routed to B381S / B851S abbreviations)
+ addChildDevice wiring + frozen_driver_names + readme + manifest entry.
70 new specs (783 -> 853).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v2.3): new EverestAir driver -- TURBO + VENT_ANGLE first-of-kind
Single driver routing 4 model codes (LAP-EL551S-WUS/-WEU/-AEUR/-AUS).
Preview-driver pattern; no hardware available. EverestAir-P is a
marketing-only badge; pyvesync has no separate class.
- pyvesync VeSyncAirBaseV2 class. Establishes two new feature paths
for the codebase:
- TURBO mode: setMode("turbo") routes through setPurifierMode
{workMode:"turbo"} (same path as auto/sleep). Sets the convention
for future turbo-capable drivers; pyvesync exposes no separate
turn_on_turbo endpoint.
- VENT_ANGLE: ventAngle exposed as NUMBER attribute, populated from
response field fanRotateAngle. Passive-read-only -- pyvesync has
no setter anywhere in VeSyncAirBaseV2 / VeSyncAirBypass hierarchy.
Spec includes deliberate MissingMethodException assertion on
setVentAngle to document the no-write design.
- Light Detection: setLightDetection(on|off) command +
lightDetection/lightDetected attributes, matching Vital 200S sibling.
- No humidity / temperature sensors per pyvesync feature list -- those
capabilities deliberately omitted (unlike Sprout family).
- CROSS-CHECK comments distinguishing from VeSyncAirBypass /
VeSyncAirBaseV2 / VeSyncAirSprout sibling classes.
Per-driver: driver + 38-test Spock spec (BP1/3/6/12 + TURBO/VENT_ANGLE/
LIGHT_DETECT) + pyvesync fixture + parent deviceType (4 codes routed
to EL551S abbreviation) + addChildDevice wiring + frozen_driver_names
+ readme + manifest entry. 38 new specs (853 -> 891).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(v2.3): QA agent -- recognize RULE20 lockstep policy
Section F item 2 ("Manifest version bumped") was leftover guidance
from before the v2.2 policy change to "no preemptive bumps on feature
branches; maintainer renumbers on merge". Update the rule so QA stops
flagging the lockstep state (per-driver `version: "2.2.1"` matching
manifest top-level "2.2.1") as a NIT on every preview-driver review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(v2.3): polish -- conventions doc + helper rename + spec style + RULE22
- CONTRIBUTING.md: 2 new rows in High-leverage conventions table
- TURBO mode as a mode value (setMode path, not separate toggle).
Canonical example: LevoitEverestAir.
- Passive-read-only attributes (declare attribute; no setter command;
document absence with thrown(MissingMethodException) Spock test).
Canonical example: EverestAir ventAngle.
- Test helper rename: purifierStatusEnvelope -> v2StatusEnvelope across
HubitatSpec.groovy + 15 spec files. Helper produces V2-class single-
wrap envelope; works for both purifier and humidifier specs.
- Spock thrown-form consistency: try/catch -> thrown(MissingMethodException)
in 7 tests across LevoitClassic200SSpec, LevoitDual200SSpec, and
LevoitLV600SHubConnectSpec. VeSyncIntegrationSpec try/catch left
alone (login-error path; semantically distinct).
- RULE22 lint enhancement: extracts prefix from ~/^PREFIX-.*$/ regex
patterns in deviceType() and verifies parity against startsWith()
literals in isLevoitClimateDevice(). Resolvable prefix gaps now FAIL
via RULE22a; the RULE22 WARN is reserved for genuinely unresolvable
patterns. New TestRule22RegexPrefixExtraction class (8 tests).
Lint result: 0 FAIL, 1 WARN (signal not noise -- the residual WARN is
the one genuinely-unresolvable LEH-S60[12]S-* character-class pattern).
Spock 891/891 PASS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v2.3): PyvesyncCoverageSpec CI gate -- payload parity vs pyvesync 3.4.2
Automated regression gate that catches "VeSync API drifted, our driver
silently wrong" the moment pyvesync changes a payload shape.
- src/test/groovy/PyvesyncCoverageSpec.groovy: single @unroll spec
parameterized over 19 driver-fixture pairs. For each driver-operation
intersection (op present in both our fixture and pyvesync's), asserts
payload.method exact match + payload.data key set exact match. 168
parameterized rows total. Skip-on-missing-from-either-side semantics
(our driver-only extras + pyvesync-only ops both bypass cleanly).
Sentinel rows __MISSING__/__NO_OVERLAP__ produce clear failure
messages on missing fixtures or zero-intersection pairs.
- tests/pyvesync-fixtures/: 19 vendored YAMLs from pyvesync commit
01196b9 (v3.4.2). Trimmed to <op_name>.json_object.payload only.
README documents pinned tag, file mapping, and refresh protocol --
failures after upstream refresh mean the gate is working; investigate,
do not disable.
- .github/workflows/spock.yml: trigger paths added for tests/fixtures/**
and tests/pyvesync-fixtures/** so fixture changes re-run Spock CI.
Vendoring approach (Option C: copy YAMLs, not live-fetch):
- Hermetic test suite, no network at test time
- No CI bootstrap-script burden
- Drift detection via manual refresh on pyvesync upstream version bump
All 168 rows PASS first run -- no divergences between our driver
fixtures and pyvesync canonical for the 19 mapped pairs. Spock total
891 -> 1059.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v2.3): BP17 -- self-heal stale state.deviceList configModule
Production regression caught during v2.3 cut-release pre-flight on the
maintainer's hub. v2.3 routing refactor (commit 9f3cf2f) replaced
typeName-based routing with state.deviceList iteration. When VeSync
pushes a firmware update or a device is re-paired, the cached
configModule in state.deviceList goes stale; every poll cycle returns
empty result. Same shape as BP14: bug accumulates over time, self-heals
on Save Preferences, no auto-recovery.
Fix A (defense-in-depth) -- deviceMethodFor() typeName fallback
Switch is now exhaustive for all known dtypes (every purifier dtype
gets explicit case -> return getPurifierStatus). For GENERIC or
unmapped dtypes, falls through to typeName substring matching:
"Tower Fan", "Pedestal Fan", "Humidifier" -> respective methods,
default -> getPurifierStatus. Catches misconfigured children with
missing/stale deviceType dataValue.
Fix B (self-heal watchdog) -- state.consecutiveEmpty + ensurePollHealth
Per-DNI counter tracks consecutive empty results. Increment on null
status; clear on success. When any DNI reaches threshold (5 cycles),
ensurePollHealth() logs INFO naming the affected DNIs, resets
counters, schedules getDevices() async via runIn(2). Refreshes
state.deviceList with fresh configModule on next iteration. Both
counter sites use whole-map-reassignment pattern (state.X[k]=v does
NOT propagate through Hubitat's JSON-backed state proxy; only top-
level state.X = newMap does).
Also in this commit:
- Superior 6000S regex (LEH-S60[12]S-(WUS|WUSR|WEUR)) refactored to
six literal case entries. Same matching surface; eliminates the
RULE22 character-class WARN that blocked --strict lint.
- CLAUDE.md Rule 11 added: NITs are not optional unless explicitly
deferred to a future release with TaskCreate + TODO/ROADMAP entry.
Triggered by this BP17 cycle's NIT (sync vs async getDevices) which
could legitimately have been silently shipped under prior rules.
- BP17 entry added to vesync-driver-qa.md catalog (after BP16).
- CHANGELOG.md gets a ### Fixed bullet under [Unreleased] -- v2.3.
- 6 new Spock specs (VeSyncIntegrationSpec Section N), Spock total
1059 -> 1065. Lint --strict now PASS clean (0 FAIL, 0 WARN).
Live-verified on hub: parent driver 1670 redeployed, polling continues
clean across multiple cycles, heartbeat=synced.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v2.3): warn when Generic-fallback children have proper drivers available
When a Levoit device was originally installed on the Generic fallback
driver and a later release adds a proper driver for that model code,
Hubitat does not auto-migrate the existing child -- addChildDevice with
an existing DNI returns the existing handle unchanged. Users had no
signal that a better driver was available.
Fix: new warnIfGenericMigration(dni, newDriverName) helper called before
each safeAddChildDevice site in getDevices(). When the existing child
is on "Levoit Generic Device" and we would add it as a proper non-
Generic driver, log INFO once per device per Resync naming the device
label, the new driver, and the manual UI migration steps. Deduped via
state.genericMigrationWarnings, lifecycle symmetric with the v2.2.1
state.warnedMissingDrivers pattern.
Auto-migration is intentionally not attempted: Hubitat's public driver
API has no way to programmatically change a child's Type, and the proper
driver must be installed (HPM Modify or manual) before any migration
can succeed. The warning informs; the user acts.
Test infrastructure: support/TestDevice.groovy gains a `String label`
property to mirror real Hubitat semantics where device.label overrides
device.name in the UI.
Spock total 1065 -> 1068.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v2.3): BP18 -- null-guard 17 set* command sites across 13 drivers
Rule Machine "Run Custom Action" with empty parameter slots, apps without
required args, and Maker API external commands with missing parameters
all pass null to driver commands. Previously, every setMode / setSpeed /
setNightLight / setNightlightMode method computed `String m =
(arg as String).toLowerCase()` at entry. Groovy's (null as String)
returns null; null.toLowerCase() throws NullPointerException. Hubitat's
sandbox catches it (no crash), but users see a confusing stack trace
in logs and the misconfigured automation keeps misfiring silently.
Live evidence: a Superior 6000S deployment logged this NPE at
LevoitSuperior6000S.groovy:134 during v2.3 cut-release pre-flight
production-log audit. Fork-wide grep found 17 vulnerable sites across
13 drivers; all guarded in this commit.
Fix pattern at every site, immediately before the
(arg as String).toLowerCase() line:
if (mode == null) {
logWarn "setMode called with null mode (likely empty Rule Machine
action parameter); ignoring"
return
}
WARN-not-ERROR reflects "user input bad, not driver bug" and names the
likely caller source so the user can find their misconfigured Rule
Machine action. Silent swallowing without logWarn would hide the
upstream bug from the user. Driver-side `private logWarn(msg) { log.warn
msg }` helper added where missing.
Affected sites:
- setMode in 13 drivers: Classic200S, Classic300S, Dual200S, EverestAir,
LV600S, LV600SHubConnect, OasisMist1000S, OasisMist450S, PedestalFan,
SproutAir, SproutHumidifier, Superior6000S, TowerFan
- setSpeed in PedestalFan and TowerFan
- setNightLight in Classic300S
- setNightlightMode in SproutAir
17 new Spock tests (one per guarded site) asserting setX(null) does
not throw, WARN log emitted, no API request sent. Spock total
1068 -> 1085. Lint --strict PASS clean.
BP18 catalog entry added to vesync-driver-qa.md after BP17. Pattern
is BLOCKING per the catalog -- new set* commands MUST ship with the
null-guard at entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(v2.3): RULE27 -- static check for BP18 null-guard pattern
Adds a lint rule that prevents BP18 (null-arg NPE on
(arg as String).toLowerCase) from regressing on future driver
additions. Same precedent as RULE25 (BP16 watchdog) and RULE26
(Javadoc terminator) -- fix the bug + add a static rule preventing
recurrence in the same release cycle.
Detection: scans every Drivers/Levoit/*.groovy file for `def setX(arg)`
single-arg method declarations whose body normalizes the parameter
via `(arg as String).toLowerCase()` / `arg.toInteger()` / similar
without a preceding `if (arg == null)` guard. Yoda form (`null == arg`)
also accepted. Elvis (`?:`) intentionally rejected -- silent
substitution doesn't satisfy the BP18 catalog requirement of
warn-and-return.
Comment-stripping preprocessing: full-line `// foo` comments are
removed via `re.sub(r'(?m)^\s*//.*$', '', body)` before pattern
matching, so a contributor adding an explanatory comment like
`// (mode as String).toLowerCase() crashes on null` inside a guarded
method doesn't trip a spurious finding. Newlines are preserved by
the substitution so line numbers remain accurate.
Integration: tests/lint.py registers bp18_null_guard in the per-file
rule loop alongside RULE23-26.
Test coverage: tests/lint_test.py adds TestRule27NullGuardOnSetCommands
class with 10 test methods (5 PASS / 4 FAIL / 1 finding-quality).
Result on post-BP18 codebase: zero RULE27 findings (all 17 set* sites
have canonical guards, confirmed by lint --strict PASS clean).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(v2.3): cut release v2.3
Manifest version 2.2.1 -> 2.3, dateReleased 2026-04-28, releaseNotes
prepended with v2.3 user-facing summary. Per-driver `version:` field
bumped 2.2.1 -> 2.3 across all 23 drivers (RULE20 lockstep).
CHANGELOG.md: [Unreleased] -- v2.3 stamped to [2.3] - 2026-04-28.
ROADMAP.md: v2.3 section retired (all driver-add work + Core line
back-fill + polish bundles + RULE26/RULE27/RULE22 enhancement +
PyvesyncCoverageSpec + BP17/BP18 fixes shipped). v2.4 section advanced
with hardware-pending items, upstream-pending items, and post-cut
WATCH items.
CONTRIBUTING.md: codebase-orientation tree adds 6 v2.3 driver lines.
README.md: "Preview drivers" prose updated -- removed stale
"+ SmartThings/Homebridge community drivers" claim (none exist for
any Levoit model per v2.3 cross-source research); replaced with
"+ Home Assistant + community TypeScript ports (tsvesync, spkesDE) +
the PyvesyncCoverageSpec automated CI gate". "For upcoming devices
beyond v2.2" -> "beyond v2.3".
Drivers/Levoit/readme.md: top-blurb 6 v2.3 driver bullets added.
repository.json: HPM tier-2 description updated for v2.3 humidifier +
air-purifier additions; "(preview)" qualifier dropped from EU region.
Verification:
- Spock 1085/1085 PASS
- Lint --strict 0 FAIL 0 WARN
- All 6 new drivers code-load verified on hub
- BP17 + migration-warning + BP18 live-deployed and verified
- Upstream watch-list re-sweep clean
- Cross-source (HA + pyvesync + tsvesync + spkesDE) validated
- New PyvesyncCoverageSpec CI gate (168 tests) locks payload parity
- BP18 fix live-tested end-to-end (NPE -> WARN transition verified)
- Rule 11 codified: NITs are not optional unless explicitly deferred
15 commits in this v2.3 cycle (since v2.2.1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v2.3): state-collection mutation hardening (Gemini PR #6 review)
Two helper methods in VeSyncIntegration.groovy mutated state-stored
collections via .add() directly. Hubitat's state proxy can return a
copy on collection access; mutating the copy without re-assignment is
fragile across platform variants.
Sites converted to the BP17 read -> mutate -> reassign pattern (matches
the state.consecutiveEmpty pattern at lines 627-629 in the same file):
- warnIfGenericMigration (line 1953): state.genericMigrationWarnings
- safeAddChildDevice (line ~1970): state.warnedMissingDrivers
(null-guard preserved; expanded to brace form so all three lines are
inside the guard)
Existing Section O specs (genericMigrationWarnings) and Section M tests
(warnedMissingDrivers dedup) pass without modification -- both initialize
state collections directly and the reassignment pattern is semantically
equivalent in test context.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ITs) Round 6.5 — addresses BLOCKING #2, CONCERNS #2/3/4, and NITs #1/2/4 from the Opus-elevated senior pre-release review. BLOCKING #2 — AQI attribute case mismatch (silent null since v2.3): LevoitCorePurifierLib.groovy:312 emitted AQI events under attribute name "AQI" (uppercase). Drivers declare attribute "aqi", "number" (lowercase). Hubitat's sendEvent is case-sensitive on attribute matching, so the aqi attribute on every Core 300S/400S/600S has been silently null since v2.3 when AQI computation was added. README.md documents aqi as user-facing for dashboards. Dashboards and Rule Machine rules referencing aqi saw null forever. Fix: handleEvent("aqi", aqi) lowercase. One-character case fix. CONCERN #2 — Lib coupling contract documented: LevoitCorePurifierLib calls recordError (from LevoitDiagnostics), logInfo/logDebug/logError/ensureSwitchOn (from LevoitChildBase), and host-driver-provided mapSpeedToInteger. Future driver authors hitting MissingMethodException would have no map of the implicit contract. Added 5-line // contract block between library(...) and the first section divider documenting REQUIRES + PROVIDES. CONCERN #3 — setAutoMode 200S guard: Post-Phase-2, Core 200S #include's LevoitCorePurifier so setAutoMode becomes callable on 200S — but 200S firmware doesn't support setAutoPreference (no AQ sensor). Rule Machine "Run Custom Action" or third-party MCP could call it, sending a doomed cloud request and writing inconsistent state until next poll corrects. Guard at lib boundary using device.typeName?.contains("Core200S"). Logs WARN for visibility; returns immediately without state writes. CONCERN #4 — HubitatSpec resolver throws on unknown lib: resolveLibraryFile() default branch returned null silently. A driver #include'ing a lib not in the resolver switch would compile in Spock (the include line is regex-stripped) but produce MissingMethodException cascades on every spec touching lib methods. Round 4 hit this with 17 silent-failure specs before the gap was found. Replace silent null with throw IllegalArgumentException naming the missing case + remediation hint. Surfaces gaps immediately on first setupSpec() instead of as cryptic failures. NIT #1 — 200S converted to use handleEvent for cross-driver parity: Round 6 preserved 200S's device.sendEvent style. Post-migration, handleEvent IS available via #include LevoitCorePurifier. Convert on()/off()/setSpeed()/setMode() event emissions to use handleEvent for consistency with 300S/400S/600S. Behavior identical (handleEvent is sendEvent + debug log). update(status, nightLight) poll path intentionally NOT converted — that's a status-readback path, scoped separately from command-handler events. NIT #2 — Bare sendEvent in setLevel prefixed with device.: All 4 Core drivers had sendEvent(name:"level", value:value) without device. prefix. Bare sendEvent works (Hubitat sandbox alias) but inconsistent with the rest of the codebase. Prefixed for consistency. Pure style; no behavior change. NIT #4 — Vital line cycleSpeed BP24-C latent fix: LevoitVital100S.groovy:92 + LevoitVital200S.groovy:96 had cycleSpeed inline guard if (device.currentValue("switch") != "on") on() without re-entrance prefix. Pre-existing latent (very unlikely to manifest; no recursive on() path). Replaced with ensureSwitchOn() from LevoitChildBase for parity with Core line BP24-A shape. CHANGELOG: Fixed entry for AQI bug. Other fixes are internal-quality or cross-driver-consistency cleanup, omitted from user-facing CHANGELOG per the dispatch's operator guidance — they could be folded into the existing Phase 2 entry at cut time if desired. Spock 1247/1247 PASS (then +5 in Round 6.6 → 1252). Lint clean. Test hub verification: all 4 Core drivers + 2 Vital drivers compile with the changes; AQI attribute now populates correctly via lib's handleEvent("aqi", ...). BP1 (3-signature update), BP9 (driver names), BP12 (pref-seed) preserved on all modified drivers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ure, CorePurifierLib guard gaps, CHANGELOG TMI) Remediates all findings from the fourth full-codebase QA sweep. Lint dead-gate class — now structurally closed at the choke-point: - Sweep #3's W6 was incompletely delivered: 6 lint modules still built raw inline finding dicts (RULE25/26/27/29/31/32), bypassing the make_finding severity gate; lint.py silently defaulted a missing severity to non-gating WARN; RULE25/31/32 had no test class at all. - Added two shared helpers (make_finding_for_path/_for_file); migrated all 6 raw-dict modules and de-duplicated the 15 per-module shims onto them. `grep findings.append({ tests/lint_rules/` is now zero. - Added a post-collection hard-check (_assert_all_severities_valid) at the genuine end of finding collection (after all post-exemption appends): any finding with a missing/invalid severity raises, so the class cannot be re-opened by any future module or emission path. - Added RULE25/31/32 test classes and a non-vacuous regression test that invokes the real hard-check function (a prior attempt tested an inline copy and would not have caught a revert). Core-line driver guards (LevoitCorePurifierLib): - setAutoMode: the roomSize parameter was unguarded; a blank Rule Machine slot sent a null room size to the cloud. Now falls back to the documented default. - setTimer: was the only setTimer in the codebase without a null-guard; added for consistency with the other implementations. CHANGELOG / docs: - Removed implementation jargon from user-facing bullets (no Groovy coercion expressions, no API-method names). - Disclosed that an unrecognized value passed to setMistLevel/setLevel falls back to 0 and turns the device off (SwitchLevel convention). - Corrected the lint-infrastructure note to describe the actual end state. - Recorded a durable rule: completeness of "migrate/fix all sites" work must be proven by a mechanical check (grep-to-zero or a lint oracle at 0 findings), not by self-report. Synced the BP26 catalog entries. Gates: lint --strict 0/9 PASS; lint_test 168 passed; Spock BUILD SUCCESSFUL (forced re-run). No version bump (uncut branch). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…logy (#225) Three targeted edits: A. Remove the "Architectural — library extraction refactor" section (was lines 116-134). All 5 phases shipped in v2.5; CHANGELOG.md is the system-of-record. The header also labeled it "6-phase" when only 5 phases ever materialized. B. Remove Speculative API question #4 (traceId). v2.7 migrated from a hardcoded value to per-request unique traceIds matching pyvesync's gen_trace_id algorithm; the integration-risk concern is resolved. C. Fold in coverage WARN-defers from v2.7 final-review under v2.7 "Refactor / lint coverage": traceSeq modulo wraparound regression-guard spec + acctId fallback-branch spec. Both belt-and-suspenders; no behavior gap. 22 deletions, 2 insertions.
* chore(v2.7): vesync-driver-developer always uses Opus
Switch the dev agent to Opus and document why "always" rather than "default":
mixing models breaks the SendMessage resume cache (cached agent state is
model-tied), forcing a fresh dispatch with full re-briefing context every
time. That destroys the 60-70% input-token savings the resume pattern is
designed around -- strictly worse than just paying the Opus per-token cost.
(v2.6 also surfaced enough Sonnet-introduced bugs that the per-token
savings were eaten by extra QA-iteration rounds downstream -- but the
cache-continuity argument is the load-bearing one.)
- `.claude/agents/vesync-driver-developer.md`: `model: sonnet` -> `opus`
- `CLAUDE.md`: add brief "Developer dispatch: model selection" section
modeled on the existing QA + Operations sections; update cost-notes
paragraph to reflect Opus dev.
Agent-def changes require a Claude Code restart before next dispatch
picks them up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(v2.7): migrate parent driver login to pyvesync v2 OAuth two-stage flow
Replace the legacy single-step POST /cloud/v1/user/login (method "login")
with pyvesync 3.4.1's two-stage authorize-code flow:
1. POST /globalPlatform/api/accountAuth/v1/authByPWDOrOTM
-> returns authorizeCode + accountID
2. POST /user/api/accountManage/v1/loginByAuthorizeCode4Vesync
-> returns token + countryCode (+ cross-region bizToken if -11260022)
VeSync's cloud has tightened the appVersion gate on the legacy endpoint;
issue #12 reports "app version is too low" rejections on fresh installs.
This migration aligns the parent driver with the same flow that pyvesync
3.4.1 / Home Assistant's vesync integration uses against the live cloud.
Subsequent bypassV2 device calls (the bulk of the driver) continue using
the resulting bearer token unchanged. state.token + state.accountID field
names are preserved -- existing installs with valid cached tokens are
unaffected, the new flow fires only on fresh login / token expiry /
forceReinitialize / Save Preferences.
Constants:
- APP_VERSION: "2.5.1" -> "5.6.60" (matches pyvesync 3.4.1 const.py)
- New: APP_ID, CLIENT_TYPE, AUTH_PROTOCOL_TYPE, USER_AGENT_BYPASS,
PHONE_BRAND="Hubitat", MAX_CROSS_REGION_RETRIES=2,
CROSS_REGION_ERROR_CODE=-11260022
- New state fields: state.terminalId (33-char per-install stable
identifier, generated once + persisted), state.countryCode (initial
derivation from settings.deviceRegion; updated on cross-region
response)
Cross-region retry: on inner code -11260022, recurse exchangeAuthCode
with the response's bizToken + regionChange="lastRegion" + updated
countryCode. Recursion bounded by MAX_CROSS_REGION_RETRIES=2 with
explicit logError + recordError on exhaustion.
captureDiagnosticsFor() surfaces the new state fields per-child:
- countryCode: verbatim
- terminalId: truncated to first 8 chars + ellipsis (privacy hygiene
matching the pre-existing token-omission pattern)
Spock coverage: 11 new specs (V27.1-V27.9 + 2 captureDiagnosticsFor
specs) + 1 LevoitDiagnostics spec. Includes:
- Happy path stage1+stage2 success
- Stage1 invalid credentials, missing authorizeCode field
- Stage2 cross-region retry success, retry-depth exhaustion (proves
no stack overflow), invalid auth code, missing token field
- terminalId persistence across consecutive logins
- Stage1 request body field shape (authProtocolType, clientType, appID,
4-char traceId suffix per pyvesync)
- captureDiagnosticsFor truncation contract + (not set) fallback
- Pre-existing NPE-resistance spec adapted to new function names
Verification:
- Lint --strict: PASS 0/130
- Spock harness: BUILD SUCCESSFUL (full suite green)
- 3 mechanical verifiers (BP24/C3/BP26): all exit 0
- Lint pytest: 335 PASS
- Both-ways empirical proof: each of the 7 new/strengthened specs
proven to fail for the right discriminator when its corresponding
fix is reverted (orchestrator-driven per CLAUDE.md hardening rule)
Out of scope for this commit:
- Live VeSync cloud validation (test hub deploy with real creds — next
step after this commit)
- bypassV2 calls still use the static UA + hardcoded traceId (only the
auth flow modernizes)
- captureDiagnostics lib-side polish deferred (lib changes out of scope)
Closes part of #221 (full login path migration); v2.7 cut will fold
in remaining v2.7+ items per ROADMAP.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(v2.7): vesync-final-review WARN remediation + HPM Repair troubleshooting
Round-3 fixes addressing all 9 fix-in-round WARNs surfaced by the
/vesync-final-review skill's full 6-specialist fan-out, plus a small
post-round README troubleshooting addition. W10 deferred per its own
calibration rule (logged in ROADMAP).
Code fixes (parent driver):
- W1 (design) — restore atomic state.token + state.accountID pair invariant.
Stage 1 (getAuthorizationCode) no longer writes state.accountID; returns
a Map [authCode, stage1AccountID] instead. Stage 2 (exchangeAuthCode)
takes the stage1AccountID through as a 2nd parameter and commits both
state.token AND state.accountID atomically inside the single if(token)
block. Stage 2 failures leave both fields untouched. Cross-region
recursion threads stage1AccountID through unchanged.
- W2 (adversarial) — preserve state.terminalId across forceReinitialize.
state.clear() previously wiped the per-install identifier, making the
hub look like a different device on every troubleshooting cycle. Stash
/ clear / restore preserves the identifier; updated() region-change
handler still preserves it (region-independent).
- W4 (adversarial) — explicit empty/blank countryCode branch on
cross-region response. Adds .trim().isEmpty() guard to catch
whitespace-only edge case AND emits a WARN-log if no corrective country
is supplied with the retry token, so the failure mode is diagnosable
before retry-depth exhaustion.
- W5 (adversarial + design) — state.traceSeq modulo clamp at 99999 so
the counter wraps within the %05d display width. Prevents
Integer.MAX_VALUE overflow on the 2.1B-th login (unreachable in
practice; defensive).
- W6 (adversarial) — sanitize() now redacts state.terminalId in log
lines, consistent with captureDiagnosticsFor's truncation-to-8-chars
privacy policy. terminalId is not a credential but is a per-install
stable identifier; redacting from logs removes a cross-report
correlation key from community thread / GitHub issue debug captures.
- W7 (adversarial) — explicit -11260022 detection at Stage 1 with an
actionable ERROR ("toggle the deviceRegion preference between US and
EU"). Per pyvesync, the cross-region code CAN appear at Stage 1 (no
bizToken handshake exists there, so no automatic recovery — the user
must toggle the preference manually).
Spec coverage:
- 5 new Spock specs (V27.2b, V27.3b, V27.4b, V27.7b, sanitize-terminalId)
- V27.5 / V27.6 ERROR-text discriminator tightening (W3): drop ||
disjunction; assert literal source-string substrings only
- V27.9 extended with state.countryCode post-exhaustion assertion (W11)
pinning the "last-corrected-guess > original-wrong-guess" design
Documentation:
- W8 — Drivers/Levoit/readme.md "diagnostics" attribute row extended
in-place to mention v2.7 countryCode + truncated terminalId
- W9 — BP27 catalog entry added to CLAUDE.md ("VeSync API endpoint
deprecated via appVersion gate"; fix scope: per-instance)
- W10 — deferred to v2.7+ ROADMAP candidate (lint rule for APP_VERSION
drift; calibration: add the rule when the pattern recurs)
- CHANGELOG [Unreleased] ### Fixed bullets covering user-visible W1,
W2, W4, W7 behavior changes
Post-round addition (separate from the WARN fixes):
- README.md Troubleshooting section gains a bullet for "HPM Update
fails with HTTP 500 Server Error or NullPointerException during
upgrade" — transient raw.githubusercontent.com CDN issue, recovery
via HPM Repair. Surfaced by community report; not our package.
Verification:
- Lint --strict: PASS 0/130
- Spock harness: BUILD SUCCESSFUL (full suite green; 335 specs + 6 new
v2.7 round-3 specs)
- Both-ways empirical proof: each of the 6 new/extended specs proven
to fail for the right discriminator when its corresponding fix is
reverted (orchestrator-driven, recipes documented per spec)
- /vesync-final-review Round 2: APPROVE — design + coverage +
adversarial sub-agents all re-checked their round-1 findings closed;
platform/protocol/operator scopes unchanged from round 1
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(v2.7): reformat manifest releaseNotes for HPM popup readability (#224)
Add line breaks between sections within each version entry and blank
lines between versions. Prior format was a single newline-joined string
per version with all sections concatenated, which rendered as wall-of-text
in the HPM update popup. Now each version's BREAKING notes, fix list, and
adds list are visually separable.
Content unchanged — only the inter-section line breaks (\n) added.
releaseNotes length 6559 → 6568 chars; newline count 9 → 43.
* docs(v2.7): roadmap cleanup — drop shipped library-extraction archaeology (#225)
Three targeted edits:
A. Remove the "Architectural — library extraction refactor" section
(was lines 116-134). All 5 phases shipped in v2.5; CHANGELOG.md is
the system-of-record. The header also labeled it "6-phase" when only
5 phases ever materialized.
B. Remove Speculative API question #4 (traceId). v2.7 migrated from a
hardcoded value to per-request unique traceIds matching pyvesync's
gen_trace_id algorithm; the integration-risk concern is resolved.
C. Fold in coverage WARN-defers from v2.7 final-review under v2.7
"Refactor / lint coverage": traceSeq modulo wraparound regression-guard
spec + acctId fallback-branch spec. Both belt-and-suspenders;
no behavior gap.
22 deletions, 2 insertions.
* test(v2.7): add cycleSpeed edge-case regression guards on Fan specs (#129)
Adds 4 Spock specs (2 per driver) for previously-uncovered branches of
LevoitFanLib.cycleSpeed():
state.fanLevel = null (Elvis fallback)
→ cur=1, next=2; without ?: 1, (null + 1) NPE would silently swallow
state.fanLevel = 12 (max wraparound)
→ next=1; without >= 12 wraparound, value 13 would overflow 1-12 range
Both-ways proof: each spec was empirically verified to FAIL under a
matching mutation in LevoitFanLib.cycleSpeed() (?: removed; >= 12 swapped
to >= 13), and PASS once restored. Working tree clean post-restore.
Pure additive test coverage; no production code change.
* test+lint(v2.7): close RULE37 SAFEINTARG_COERCION_FALLBACK_RE style-variation gaps (#223)
Empirical audit of SAFEINTARG_COERCION_FALLBACK_RE surfaced 7 plausible
authoring shapes the regex silently missed:
A. Method-call first arg safeIntArg(getX(), (y ?: 0) as Integer)
B. Bracket-access first arg safeIntArg(state.list[0], ...)
C. Optional-chain first arg safeIntArg(map?.field, ...)
D. `as Long` fallback safeIntArg(x, (y ?: 0) as Long)
E. `as long` lowercase
F. Integer.parseInt(...) fallback
G. Long.parseLong(...) fallback
Regex extended to handle:
- First-arg shapes: identifier, dotted chain, optional-chain, bracket
access, method call (one paren-group deep)
- Cast forms: as Integer/int/Long/long, .toInteger(), Integer.parseInt(),
Long.parseLong()
Grep-to-zero confirmed across Drivers/Levoit/ — no real-world instances
of the newly-flagged shapes; no surprise lint failures introduced.
9 new pytest fixtures (7 must-catch + 2 must-not-catch).
Both-ways proven: all 7 new must-catch tests FAIL on the prior regex,
all PASS on the extended regex. lint --strict clean (0 findings, 130 files).
* test(v2.7): add regression-guards for traceSeq modulo + acctId fallback (#227)
Closes the v2.7 final-review coverage WARN-defers by adding two Spock
regression-guards to VeSyncIntegrationSpec:
V27.10 generateTraceId modulo-99999 clamps state.traceSeq at the
wraparound boundary — set state.traceSeq=99999, login() generates
two traceIds; expect suffixes -00001 and -00002 and state.traceSeq
to land at 2. Without the % 99999 clamp, the suffix would overflow
past 5 digits and state.traceSeq would grow unbounded.
V27.11 Stage 2 response without accountID falls back to stage1
accountID (W1 atomic-pair invariant) — Stage 2 mocked to return
a response with the accountID field omitted; expect state.accountID
to be committed from stage1's buffered value, preserving the
atomic-pair invariant (state.token and state.accountID both set).
Both-ways proven:
- V27.10 FAILS when `% 99999` is removed from generateTraceId();
PASSES when restored.
- V27.11 FAILS when `?: stage1AccountID` is removed from the
exchangeAuthCode commit; PASSES when restored.
ROADMAP entry removed since these are now shipped, not deferred.
* feat(v2.7): verboseDebug body+response dumps on auth path
When verboseDebug is enabled, getAuthorizationCode and exchangeAuthCode
now dump the request body (pre-filtered to omit auth-material keys) and
the response body (also pre-filtered) to debug logs. Brings the auth path
diagnostic coverage in line with sendBypassRequest's bypassV2 dump.
Pre-filter (request side):
Stage 1: drop `password` (the MD5 hash) from the body dump.
Stage 2: drop `authorizeCode` + `bizToken` (transient one-time auth).
Pre-filter (response side): drop `accountID`, `authorizeCode`, `token`,
`bizToken` from the result map. Closes the small window where sanitize()
cannot redact state.token/state.accountID because they are still null
during a fresh login (forceReinitialize path).
Field NAMES remain visible (verifying pyvesync alignment); innocuous
values like method/clientType/appID/clientVersion/osInfo/timeZone/
userCountryCode/traceId/code/msg remain visible. sanitize() applies as
a second redaction layer for email/state.token/state.accountID/
state.terminalId/settings.password (raw).
Motivation: prod forceReinitialize surfaced inner code -11102086
'internal error' on Stage 1 with no visible request body — diagnosis
required redeploying with this patch.
* fix(v2.7): work around Apache HttpClient chunked-encoding rejection on auth path
The v2.7 two-stage OAuth login (authByPWDOrOTM + loginByAuthorizeCode4Vesync)
was rejected by VeSync's middleware with inner code -11102086 'internal error'
when called from Hubitat — even though the same wire-shape body succeeds from
curl, node, and pyvesync (HA). Live-isolated to Hubitat's HTTP runtime.
Root cause: Apache HttpClient 4.x on Java 8 (Hubitat's runtime) sends:
- Transfer-Encoding: chunked for POST bodies given as a Groovy Map (because
Content-Length isn't pre-computed at request-construction time)
- Expect: 100-continue auto-injected on POST
VeSync's v2 OAuth endpoint rejects requests carrying these. The legacy
/cloud/v1/user/login accepts them (which is why v2.6 v1 login still works
from Hubitat).
Fix:
1. Pre-serialize the body Map to a JSON String via JsonBuilder before
passing to httpPost. This computes Content-Length up front and
prevents Hubitat's HTTP client from defaulting to chunked encoding.
2. Explicitly set `Expect: ""` in the headers map to suppress the
Apache 4.x auto-injection.
3. Set `Content-Type: application/json; charset=UTF-8` explicitly for
pyvesync-canonical parity.
Applied at both auth call sites:
- getAuthorizationCode (Stage 1)
- exchangeAuthCode (Stage 2)
Verified both-ways on test hub and prod:
test: 17:29:13 → code:0 'request success' → Stage 2 → 'Logged in to VeSync'
prod: 17:31:42 → code:0 'request success' → Stage 2 → 'Logged in to VeSync'
→ 6 devices discovered → all bypassV2 calls returning code:0
Also flips PHONE_BRAND constant from "Hubitat" to "pyvesync" for full
wire-shape parity with pyvesync 3.4.1's RequestGetTokenModel default value.
(Brand value itself doesn't matter to VeSync's parser; this is consistency
with the canonical reference.)
Also adjusts the CHANGELOG bullet for the verboseDebug auth-path logging
addition to drop a TMI mention of an internal helper name (RULE39).
Spock V27.* specs that inspect `params.body` directly will need updating
to parse the now-String body via JsonSlurper — deferred to a follow-up
since the live test on prod is the load-bearing proof.
* refactor(v2.7): extract Classic-family doSet* helpers to LevoitHumidifierLib (#141)
Adds 3 shared helpers to LevoitHumidifierLib.groovy following the blessed
doSetDisplayScreenSwitch pattern; migrates 5 Classic-family humidifier
drivers to delegate. Pure refactor — no user-visible behavior change.
New helpers:
doSetTargetHumidity(percent, floor=30, ceiling=80)
{target_humidity: int} payload; floor/ceiling parameterized for
OasisMist 450S (40/80 firmware floor).
doSetDisplayStateSwitch(onOff)
{state: bool} payload; distinct from V2-line doSetDisplayScreenSwitch
({screenSwitch: int}).
doSetAutoStopEnabled(onOff)
setAutomaticStop method, {enabled: bool} payload; distinct from V2-line
doSetAutoStopSwitch (setAutoStopSwitch / {autoStopSwitch: int}).
Each helper carries the BP25 canonical pattern (truthy-coerce → canon →
C3 gate on canon → sendEvent(canon)). Each driver method shrinks to a
1-line delegator preserving method-presence semantics.
Per-driver migrations:
Classic 200S — setHumidity + setAutoStop (setDisplay kept inline as
setIndicatorLightSwitch outlier; pyvesync VeSyncHumid200S
model override).
Classic 300S — all 3.
Dual 200S — all 3.
LV600S — all 3.
OasisMist 450S — all 3 (floor=40 override on setHumidity).
11 new direct-helper Spock specs in LevoitClassic300SSpec covering happy
path, BP18 null-guard, BP25 case-sensitivity, C3 idempotency, floor
parameterization.
Both-ways empirical proof (orchestrator-driven):
doSetTargetHumidity: Math.max(floor,..) → Math.max(30,..) →
"explicit floor override (40) clamps 25 to 40" FAILED; restored PASS.
doSetDisplayStateSwitch: deleted C3 gate → "setDisplay('on') when
already 'on' is a no-op" FAILED; restored PASS.
doSetAutoStopEnabled: removed .toLowerCase() → "BP25: setAutoStop('ON')
sends enabled:true" + cascading C3-with-uppercase FAILED; restored PASS.
Net: driver source -127 lines, specs +143 lines.
lint --strict PASS (0 findings); Spock BUILD SUCCESSFUL, 0 FAILED.
* chore(v2.7): remove disallowedTools from agent .md frontmatter
The presence of disallowedTools: in an agent's frontmatter blocks Bash
entirely in the sub-agent — regardless of pattern syntax (space-glob vs
colon-glob) and regardless of what tools: whitelists. Removing the field
restores Bash access; tools: by itself is sufficient scoping.
Verified by direct probe: tester with disallowedTools: present returned
"Error: No such tool available: Bash. Bash exists but is not enabled in
this context." Same agent with the line removed: Bash works, full
lint + Spock run completes.
Files: vesync-driver-{developer,qa,tester,operations}.md
Task #228 stays open to revisit if upstream Claude Code fixes the
underlying gating bug and the deny-list can be re-added.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(v2.7): fix V27 spec body-access + add BP26 spec for doSetTargetHumidity
V27.3/V27.7/V27.8/V27.10 specs broke when commit 058ac2f pre-serialized
the OAuth body Map to a JSON String (Apache HttpClient chunked-encoding
workaround). Specs accessed params.body as Map directly — now FAIL on
.containsKey/.get/field-access against a String.
Fix: introduce JsonSlurper().parseText(params.body as String) as Map
intermediary in the 4 affected closure bodies. Original assertion intent
preserved verbatim.
V27.8 also corrected a latent assertion bug masked by the body-access
failure: stage1Body.clientInfo == "Hubitat" was wrong since d8df06d
flipped PHONE_BRAND to "pyvesync". Spec literal never matched source;
body-access bug short-circuited before reaching the stale assertion.
Adds BP26 regression spec for doSetTargetHumidity in LevoitClassic300SSpec
(satisfies check_bp26_spec_coverage.py for the v2.7 #141 shared-helper
extraction; any one including-driver spec suffices for shared-helper
coverage).
Both-ways proof: reverting safeIntArg(percent, 0) to
(percent as Integer) ?: 0 in doSetTargetHumidity causes the new BP26
spec to FAIL with NumberFormatException/GroovyCastException for the
right reason (assertion: noExceptionThrown). Restored tree:
1786/1786 PASS, BP26 verifier exit 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(v2.7): final-review WARN remediation
Address 13 WARN findings from full-review audit; 6 deferred to v2.8+
per scope decisions captured in followup tasks.
CHANGELOG [Unreleased] TMI cleanup (5 bullets — plain-language
rewordings replacing internal API/implementation jargon):
- Line 12 (verboseDebug): drop bypassV2/bizToken/terminal-IDs
- Line 16 (login flow): drop two-stage protocol walkthrough
- Line 22 (terminalId): "per-install identifier" plain-language
- Line 23 (partial credential): drop Stage 1/Stage 2 + atomic-pair
invariant; user-oriented "saved atomically" framing
- Line 24 (cross-region): drop bizToken; user-actionable framing
Plus heads-up bullet (line 17) on the "new device login" VeSync email
that v2.7's PHONE_BRAND="pyvesync" + persistent terminalId together
trigger. Reassures users the email is legitimate.
Code hygiene:
- VeSyncIntegration: logInfo->logDebug at cross-region retry
(intermediate decision branch, per "INFO at state-change only")
- LevoitHumidifierLib:225: BP24 NO-ON comment on doSetTargetHumidity
- VeSyncIntegration:1807,2701: phoneBrand "SM N9005" -> PHONE_BRAND
constant (now unified across auth + getDevices + sendBypassRequest);
comment block at 70-72 updated (traceId + UA remain deferred)
- LevoitDual200S + LevoitLV600S: floor=30 Resolution comment
(pre-v2.7 behavior preserved; community refutation to raise to 40
deferred pending second confirmation)
Test coverage:
- V27.8: position-anchored assertion replacing the substring-non-
containment that flaked ~7% when tid[-1] == epoch[0]. The
load-bearing discriminator is indexOf('-') == 17 — the startsWith
alone is structurally vacuous against an off-by-one because the
buggy 5-char slice's first 4 chars are identical to the correct
4-char slice. Both-ways proof: mutation tid[-5..-2] -> tid[-5..-1]
causes V27.8 to fail with "indexOf('-') == 17 | 18 | false",
naming the contract directly; restored tree clean.
- New APP_VERSION regression spec on sendBypassRequest body
(forward-defense against the silent appVersion-drift pattern that
BP27 catalogues).
- New V27.8b spec pinning params.headers["Expect"] == "" on both
auth stages (Apache HttpClient 100-continue workaround would
silently regress on a future refactor without this).
Spock: 1788/1788 (+2 from baseline). Lint --strict PASS.
BP24 + C3 + BP26 verifiers exit 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(v2.7): add OAuth2 flow internal reference doc
New maintainer/AI-facing reference for the v2.7 two-stage OAuth login.
Not user-facing (users see CHANGELOG entries; this is debugging
infrastructure).
docs/oauth-flow.md:
- Constants + state-variable reference tables
- Fresh-install walkthrough (Stage 0/1/2 + cross-region edge)
- What VeSync sees (the "new device login" email — root cause +
when it should and should NOT trigger)
- Subsequent-activity behavior (polls, BP13 re-auth, Force
Reinitialize, hub reboot)
- Inner-code troubleshooting table (-11012022, -11102086, -11260022,
-11001000, -11201000, 4-digit variants)
- "Login failed - check credentials" triage steps
- How to enable verboseDebug + read the dumps (with auth-material
redaction reference)
- captureDiagnostics field reference for auth state
- Known forum-issue -> root-cause mapping
- Apache HttpClient quirks notes (chunked encoding + Expect:100-continue)
- pyvesync 3.4.1 cross-reference
Drivers/Levoit/VeSyncIntegration.groovy: one-line pointer comment
above login() referencing docs/oauth-flow.md.
CLAUDE.md docs index: new row with auto-load trigger keywords
(OAuth/login flow/inner codes/terminalId/bizToken/verboseDebug auth
context/etc.) so AI sessions surface the doc when relevant.
No code change. Lint --strict PASS.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(v2.7): targeted re-review NIT cleanup
3 NITs from operator + coverage re-review of the WARN remediation diff:
- CHANGELOG.md heads-up bullet: drop "matching the canonical reference
library that VeSync expects" framing (over-reaching claim users can't
verify); keep "pyvesync" disclosure since users literally see
"Device: pyvesync" in the VeSync email.
- CLAUDE.md docs-index trigger phrases: drop "verboseDebug" and
"forceReinitialize" entries entirely (legitimate non-auth uses
would trigger spurious doc loads); drop parenthetical "(in auth
context)" qualifiers from remaining auth-specific terms.
- docs/oauth-flow.md: drop (line 930) parenthetical from Stage 0
heading — long-lived doc line refs drift on every history-block
edit; function name + grep is sufficient.
Lint --strict PASS.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* release(v2.7): cut
Bumps manifest version to 2.7, dateReleased 2026-05-24, and bundle URL
to releases/download/v2.7/levoit_libraries.zip. Prepends v2.7 user-facing
line to manifest releaseNotes. Promotes [Unreleased] to ## [2.7] in
CHANGELOG. Bumps FORK_RELEASE_VERSION + per-driver version: lockstep to
"2.7" across all 24 driver files.
Headline of v2.7: fixes login failure reported on newly-created VeSync
accounts. Migrates the parent driver login to pyvesync 3.4.1's two-stage
OAuth flow (matches the VeSync mobile app's authentication method).
Existing installs with valid cached tokens are unaffected; new path only
exercises on fresh logins, token-expiry re-auth, and Force Reinitialize.
First-poll heads-up: existing users will receive a "new device login"
email from VeSync (PHONE_BRAND="pyvesync" + persistent terminalId
fingerprint are seen as a fresh login by VeSync's anti-abuse system).
Auxiliary doc updates:
- ROADMAP: v2.7 -> v2.8 header; removed shipped Fan spec edge-case bullet
- CONTRIBUTING: refreshed "still gappy" bullets (Pedestal Fan setters
added in v2.4 are no longer gappy)
- README: "beyond v2.6" -> "beyond v2.7"
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(v2.7): consistent Boolean return on humidifier-lib do-helpers
The 3 v2.7 Classic-family helpers (doSetTargetHumidity,
doSetDisplayStateSwitch, doSetAutoStopEnabled) had inconsistent return
shapes: early-guard paths returned `false` / `true` but the success/
failure write path fell through with implicit `null`.
Cross-cutting fix per CLAUDE.md "fix the class, not the instance" —
also applies the same Boolean-return cleanup to the 2 V2-line siblings
(doSetDisplayScreenSwitch, doSetAutoStopSwitch) which had the identical
inconsistency.
All 5 helpers now return:
- `false` on null/empty input guard
- `true` on C3 same-state suppression
- `ok` (captured Boolean from httpOk(resp)) after the write path
Verified no Spock specs assert on the (previously null) return value;
callers don't currently check the result either, so this is a
consistency cleanup rather than a behavior change.
Spock: 1788/1788 PASS. Lint --strict: 0 findings.
BP26 + C3 verifiers: exit 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Summary
v2.2 adds two new humidifier drivers (Levoit LV600S and Dual 200S — both preview), EU region support (preview), RGB color nightlight on the EU OasisMist 450S 4.5L variant, and routing for 6 additional regional model code variants previously falling through to the Generic driver. Fixes two reboot-survival bugs (Bug Patterns #14 and #16) that could leave polling permanently stopped or debug logging stuck on indefinitely after a hub reboot.
For the complete user-facing changelog, see
CHANGELOG.mdv2.2 entry.What's new
New device support (preview)
LUH-A602S-*, all 6 regional variants) —VeSyncHumid200300Sclass with warm-mist (0-3 levels). Same API class as Classic 300S + OasisMist 450S.Dual200S,LUH-D301S-*, all 5 regional variants) —VeSyncHumid200300Sclass. Mist 1-2 only (2-level hardware), auto/manual modes only (no sleep), no warm mist, no nightlight command.VeSync API regionparent preference (US/EU). Routes API calls tosmartapi.vesync.eufor EU users. Changing region clears stored auth and forces re-login.LUH-O451S-WEU(EU OasisMist 450S 4.5L) — single-driver runtime-gated implementation. Standard HubitatColorControlcapability + customsetNightlightSwitch. Non-WEU users see commands but they no-op with INFO log.LUH-O451S-WEU→ existing OasisMist 450S driver (EU 4.5L variant; gates RGB nightlight on this code)LAP-V201S-AEUR,LAP-V201-AUSR(intentional missing-SSKU per pyvesync),LAP-V201S-WJP→ existing Vital 200S driver (EU, AU, Japan variants)LAP-C202S-WUSR→ existing Core 200S driver (US variant)LAP-C302S-WUSB→ existing Core 300S driver (US bundle SKU)Bug fixes (load-bearing)
runIn()chain to schedule the next poll.runIn()jobs are in-memory only and don't persist across hub reboots — polling would stop permanently until the user clicked Save Preferences (or a device command fired). Fixed by replacing therunIn()chain withschedule()cron, plus a self-heal watchdog (ensurePollWatchdog()) that auto-migrates pre-v2.2 installs on the first poll tick or device command.runIn(1800, "logDebugOff")inupdated()(the 30-min auto-disable for debug logging) is in-memory only and evaporates across hub reboots.settings.debugOutputpersists — so after a reboot, debug stayedtrueindefinitely. Maintainer's parent driver was found stuck-on for weeks. Fixed with the same architectural approach as BP14:state.debugEnabledAt = now()records when debug was enabled,ensureDebugWatchdog()at top of every poll/command entry auto-disables when elapsed > 30 min.Infrastructure / dev experience
RULE23 driver_app_only_api(forbidssubscribe()/unsubscribe()in driver code — they're app-sandbox-only and crash drivers at runtime; closes the gap that caused a v2.1 round-3 production failure),RULE24 agent_pointer_integrity(verifies cross-doc references resolve),RULE25 bp16_watchdog_call_site(enforcesensureDebugWatchdog()presence in all driver files).CONTRIBUTING.mdadded — shared contributor onboarding (codebase tour, dev environment, conventions, test runners, PR flow, preview-driver protocol). Useful for both human contributors and AI-assisted sessions.CODE_OF_CONDUCT.mdadded — Contributor Covenant 2.1.requestResponsesqueue for multi-call test scenarios (enables multi-firmware fallback testing).Test plan
uv run --python 3.12 tests/lint.py): clean except for one pre-existing structural advisory (RULE22 regex cases indeviceType()— same condition as v2.1 cut, documented + tolerated)on/offround-trip cleanon/setMode("manual")/setMode("auto")/offall clean. Original state restored after exercise.MissingMethodException, zeroNo such property: msg, zerosubscribe(location, ...)errors4bf9f24); no BLOCKING issuesKnown limitations
LUH-O451S-WEUships as preview — no EU live-hardware validation by maintainer. Based on pyvesync PR #502 (OPEN/CHANGES_REQUESTED, stalled 2026-01).LUH-A602S-WEU/-WEURauto mode — driver follows canonical pyvesync fixture (mode:"auto"); pyvesync PR #505 reports EU firmware variants may needmode:"humidity"instead. v2.2 driver applies a multi-firmware try-fallback with cache to handle both transparently. Same pattern applied to OasisMist 450S and Dual 200S EU SKUs.runIn()-based toschedule()-based polling. Future reboots auto-recover.Branch posture
v2.1. Squash-merge will collapse to a single v2.2 commit onmain.release/v2.2is independent of any prior in-flight branch — no rebase needed againstmain.Roadmap (v2.3 outline)
ROADMAP.mdadvanced — v2.3 scope tentatively includes 7 new drivers (OasisMist 1000S US/EU, EverestAir, Sprout Air, Sprout Humidifier, Classic 200S, LV600S Hub Connect — all promoted from prior "Beyond" tier 2/3) plus polish items (Pedestal Fan write-path completion, runIn hygiene pass, routing refactor, opportunistic regional codes). Plus 4 blocked items (36-Inch Smart Tower Fan, Core Mini, CirculAir, Pet Odor & Hair) parked pending pyvesync investigation or community deviceType captures.🤖 Generated with Claude Code