Skip to content

feat: implement passcode management and cloud synchronization for SwitchBot Keypad#518

Open
deece wants to merge 3 commits into
sblibs:mainfrom
deece:keypad-passcode-sync
Open

feat: implement passcode management and cloud synchronization for SwitchBot Keypad#518
deece wants to merge 3 commits into
sblibs:mainfrom
deece:keypad-passcode-sync

Conversation

@deece

@deece deece commented Jun 13, 2026

Copy link
Copy Markdown

Implement passcode management and cloud synchronization for SwitchBot Keypad

This Pull Request adds complete local and cloud passcode management and clock
synchronization capabilities for the non-vision SwitchBot Keypad (WoKeypad).

It also integrates the passive attempt_state advertisement property and tests proposed in PR #488 (by @italo-lombardi).

Why Cloud Sync is Needed:

  • Keypad passcodes are stored and validated locally (allowing the Keypad to directly command the Lock offline without requiring internet connectivity).
  • However, for security reasons, the Keypad's BLE protocol is write-only for passcodes. There is no command to read back or list saved codes over BLE.
  • Since the SwitchBot app cannot query the Keypad to build its UI passcode list, it relies entirely on the SwitchBot Cloud database to track which codes exist.
  • If a passcode is added locally over BLE but not synced to the cloud, it will successfully operate the Lock offline, but will be completely invisible in the official mobile app. Integrating the cloud sync option ensures the app UI stays in sync with the keypad's physical storage.

What Was Implemented:

  1. Passcode Management:
    • add_password: Sends passcode bytes over BLE to register a new PIN code on the keypad. Parses response to retrieve the device-assigned index.
    • modify_password: Modifies an existing passcode by index. Overwrites values and configures active duration ranges.
    • delete_password: Instantly deletes a passcode from device memory by index.
    • get_password_count: Queries counts of stored PINs, NFC tags, fingerprints, and duress credentials.
  2. Clock Synchronization (RTC):
    • sync_time: Synchronizes the internal device clock with millisecond precision (sending 8-byte big-endian milliseconds timestamp). Automatically scales second-level timestamps for backwards compatibility.
    • Note: Clock querying (get_time) was tested but found to return error 05 (unsupported) by the keypad hardware, indicating it is a write-only clock, so it has been dropped from the implementation.
  3. SwitchBot Cloud Sync Option:
    • Added keyword-only parameters to add_password: session, token, region, name, and creator.
    • If provided, automatically triggers an HTTP POST request to API function 4245 to register the passcode in the SwitchBot Cloud database so it appears correctly in the official smartphone app.
  4. Passive Advertisement Property Integration (from Add SwitchbotKeypad device class for classic Keypad #488):
    • Exposes attempt_state property via _get_adv_value in SwitchbotKeypad.
    • Adds the missing KEYPAD_INFO test fixture to tests/__init__.py.

Testing Methodology:

  1. Automated Tests (tests/test_keypad.py):
    • Created full coverage suite for add/modify/delete password, counts, and sync_time.
    • Implemented test_add_password_with_cloud_sync mocking api_request to verify payload structures, regional routing, and authorization headers.
    • Added advertisement parsing unit tests for battery percentage and attempt state.
    • Verified that all 14 unit tests pass cleanly.
  2. Manual/Hardware Verification:
    • Tested using test_hardware.py on a physical WoKeypad and Lock.
    • Verified that syncing clock with millisecond timestamps sets the correct RTC, allowing time-limited (temporary) passcodes to evaluate successfully offline and unlock the lock.

Co-authored-by: Alastair D'Silva alastair@d-silva.org
Co-authored-by: Antigravity antigravity@google.com

@deece deece force-pushed the keypad-passcode-sync branch 4 times, most recently from 81d7288 to d10c932 Compare June 13, 2026 13:05
@bdraco bdraco requested a review from Copilot June 20, 2026 14:08
@bluetoothbot

bluetoothbot commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Previous review — superseded by a newer review below.

@bluetoothbot bluetoothbot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking issues found.

  • MagicMock used in annotations but never imported — test module fails to import
  • Cloud sync silently skipped on partial credentials; BLE write already committed if cloud call then fails

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds full BLE passcode management and RTC clock synchronization for the classic SwitchBot Keypad (WoKeypad), with an optional SwitchBot Cloud “passcode list” synchronization path, plus advertisement parsing coverage for attempt_state.

Changes:

  • Introduces SwitchbotKeypad device implementation with add/modify/delete passcode APIs, passcode counts, and RTC sync.
  • Adds a cloud-sync option to add_password that posts passcode metadata to SwitchBot Cloud (functionID 4245).
  • Adds unit tests and an advertisement fixture for keypad battery and attempt_state.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.

File Description
switchbot/devices/keypad.py New Keypad device class implementing passcode + time sync APIs and optional cloud sync.
switchbot/__init__.py Exports SwitchbotKeypad from the package public surface.
tests/test_keypad.py Adds coverage for passcode ops, password counts, RTC sync, cloud sync payload shape, and adv parsing.
tests/__init__.py Adds KEYPAD_INFO advertisement fixture used by keypad tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/test_keypad.py Outdated
Comment thread switchbot/devices/keypad.py Outdated
Comment thread switchbot/devices/keypad.py Outdated
Comment thread switchbot/devices/keypad.py
Comment thread switchbot/devices/keypad.py Outdated
Comment thread tests/test_keypad.py Outdated
Comment thread switchbot/devices/keypad.py
Comment thread switchbot/devices/keypad.py
@deece deece force-pushed the keypad-passcode-sync branch from 599ce3f to 3910589 Compare June 21, 2026 02:08
@codecov

codecov Bot commented Jun 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.61905% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
switchbot/devices/keypad.py 97.60% 4 Missing ⚠️
Files with missing lines Coverage Δ
switchbot/__init__.py 100.00% <100.00%> (ø)
switchbot/devices/keypad.py 97.61% <97.60%> (+97.61%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@deece deece force-pushed the keypad-passcode-sync branch from 3910589 to 0d95e5e Compare June 21, 2026 02:15
@deece deece changed the title Implement passcode management and cloud synchronization for SwitchBot Keypad feat: Implement passcode management and cloud synchronization for SwitchBot Keypad Jun 29, 2026
italo-lombardi and others added 2 commits June 29, 2026 20:12
… Keypad

This patch adds complete local and cloud passcode management and clock
synchronization capabilities for the non-vision SwitchBot Keypad (WoKeypad).

What Was Implemented:
1. Passcode Management:
   - `add_password`: Sends passcode bytes over BLE to register a new PIN code on the keypad. Parses response to retrieve the device-assigned index.
   - `modify_password`: Modifies an existing passcode by index. Overwrites values and configures active duration ranges.
   - `delete_password`: Instantly deletes a passcode from device memory by index.
   - `get_password_count`: Queries counts of stored PINs, NFC tags, fingerprints, and duress credentials.
2. Clock Synchronization (RTC):
   - `sync_time`: Synchronizes the internal device clock with millisecond precision (sending 8-byte big-endian milliseconds timestamp). Automatically scales second-level timestamps for backwards compatibility.
   - Note: Clock querying (`get_time`) was tested but found to return error 05 (unsupported) by the keypad hardware, indicating it is a write-only clock, so it has been dropped from the implementation.
3. SwitchBot Cloud Sync Option:
   - Added keyword-only parameters to `add_password`: `session`, `token`, `region`, `name`, and `creator`.
   - If provided, automatically triggers an HTTP POST request to API function 4245 to register the passcode in the SwitchBot Cloud database so it appears correctly in the official smartphone app.
4. Passive Advertisement Property Integration (from sblibs#488):
   - Exposes `attempt_state` property via `_get_adv_value` in `SwitchbotKeypad`.
   - Adds the missing `KEYPAD_INFO` test fixture to `tests/__init__.py`.

Testing Methodology:
1. Automated Tests (tests/test_keypad.py):
   - Created full coverage suite for add/modify/delete password, counts, and sync_time.
   - Implemented `test_add_password_with_cloud_sync` mocking `api_request` to verify payload structures, regional routing, and authorization headers.
   - Added advertisement parsing unit tests for battery percentage and attempt state.
   - Verified that all unit tests pass cleanly and achieve 100% test coverage.
2. Manual/Hardware Verification:
   - Tested using test_hardware.py on a physical WoKeypad and Lock.
   - Verified that syncing clock with millisecond timestamps sets the correct RTC, allowing time-limited (temporary) passcodes to evaluate successfully offline and unlock the lock.

Why Cloud Sync is Needed:
- Keypad passcodes are stored and validated locally (allowing the Keypad to directly command the Lock offline without requiring internet connectivity).
- However, for security reasons, the Keypad's BLE protocol is write-only for passcodes. There is no command to read back or list saved codes over BLE.
- Since the SwitchBot app cannot query the Keypad to build its UI passcode list, it relies entirely on the SwitchBot Cloud database to track which codes exist.
- If a passcode is added locally over BLE but not synced to the cloud, it will successfully operate the Lock offline, but will be completely invisible in the official mobile app. Integrating the cloud sync option ensures the app UI stays in sync with the keypad's physical storage.

Co-authored-by: Alastair D'Silva <alastair@d-silva.org>
Co-authored-by: Antigravity <antigravity@google.com>
@deece deece force-pushed the keypad-passcode-sync branch from 0d95e5e to a9f1048 Compare June 29, 2026 10:12
@bluetoothbot

Copy link
Copy Markdown
Collaborator

❌ Permission denied. Only users with write access can trigger bot commands.

@deece deece changed the title feat: Implement passcode management and cloud synchronization for SwitchBot Keypad feat: implement passcode management and cloud synchronization for SwitchBot Keypad Jun 29, 2026
@deece deece requested a review from bluetoothbot June 29, 2026 10:21
@bluetoothbot

Copy link
Copy Markdown
Collaborator

PR Review — feat: implement passcode management and cloud synchronization for SwitchBot Keypad

Solid, well-tested device implementation; a few partial-failure and input-validation gaps to tighten before merge. No blocking criticals.

Note: the existing CHANGES_REQUESTED comments about a missing MagicMock import and a type-shadowing parameter name are already addressed in this HEAD — line 4 imports MagicMock and the parameter is named passcode_type. Those Copilot/bot comments were written against an earlier revision.

What's done well:

  • Command-byte construction is correct and tightly verified — chunking, packet_info nibble packing, and the 570F520203 time-window frame all match the asserted hex in the tests.
  • 14 tests cover the real branches: BLE failure, short response, time-window failure, cloud-sync payload shape, regional routing, and a cloud-failure rollback path.
  • Cloud sync failure rolls back the on-device passcode (with nested-failure logging) — good instinct for keeping device and cloud in sync.
  • Parser integration is real: process_wokeypad already populates battery/attempt_state, the KEYPAD_INFO fixture matches (mfr_data[6]=0x8f=143, data[2]&0x7f=100), and the adv tests exercise it.
  • MRO and __init__/verify_encryption_key correctly mirror the established SwitchbotKeypadVision pattern.

What needs attention:

  • Time-window write failure after a successful add leaves an orphan passcode with no rollback and no index returned to the caller (warning).
  • passcode_type and start_time/end_time are unvalidated — out-of-range values yield silent device/cloud desync or a cryptic OverflowError (suggestions).
  • region is interpolated into the API host (and carries the auth token) without normalization (suggestion).
  • get_basic_info/get_password_count can IndexError on truncated multi-byte replies — the PR fix: guard device get_basic_info parsers against short responses #500 robustness class (suggestion).
  • Test module mixes unittest.IsolatedAsyncioTestCase with the repo's pytest convention (suggestion).

🟡 Important

1. Orphan passcode when time-window write fails after add succeeds
switchbot/devices/keypad.py:207-212

After the add command succeeds, assigned_index is now physically committed to the keypad. If the follow-up time_cmd write fails, the method raises SwitchbotOperationError and the caller never receives assigned_index.

Why it matters: the passcode is already stored on the device but the caller has no index to reference it, so it can't be reached via modify_password/delete_password. Repeated failures silently accumulate unreferenceable codes in the keypad's limited credential storage — the same class of partial-failure the cloud-sync path already guards against with a rollback.

Fix options:

  • Roll back (await self.delete_password(assigned_index)) on time-window failure, mirroring the cloud-sync rollback, or
  • Include assigned_index in the raised exception so the caller can clean up.

Note the cloud path does roll back — this BLE-only path is the inconsistent gap.

time_result = await self._send_command(time_cmd)
if not time_result or time_result[0] != 0x01:
    raise SwitchbotOperationError(
        "Failed to set active time window for passcode."
    )

🟢 Suggestions

1. Out-of-range start_time/end_time raises cryptic OverflowError
switchbot/devices/keypad.py:207

start.to_bytes(4, 'big') / end.to_bytes(4, 'big') raise OverflowError (not a clear ValueError) for negatives or values > 2^32-1.

Why it matters: a caller who accidentally passes a millisecond timestamp (easy to do, since sync_time accepts milliseconds) gets an opaque int too big to convert instead of an actionable message. Validate 0 <= start <= 0xFFFFFFFF (and same for end) and raise a ValueError naming the offending argument. Same applies to modify_password.

time_cmd = f"570F520203{assigned_index:02X}{start.to_bytes(4, 'big').hex().upper()}{end.to_bytes(4, 'big').hex().upper()}"
2. passcode_type accepted unvalidated; silently coerced for cloud
switchbot/devices/keypad.py:131

passcode_type is documented as 0-3 but never validated. The raw value is written to the device, while the cloud mapping key_types.get(passcode_type, "permanent") silently falls back to "permanent" for anything out of range.

Why it matters: an out-of-range value (e.g. 4) is sent verbatim to the keypad but recorded as permanent in the cloud — the device and the app UI now disagree about the credential type, which is exactly the desync this PR exists to prevent. Reject unsupported values early with a ValueError. modify_password has the same gap.

key_type_str = key_types.get(passcode_type, "permanent")
3. Caller-supplied region interpolated into API host without validation
switchbot/devices/keypad.py:250

region is interpolated into f"wonderlabs.{region}" to build the request host, and the authorization token is sent to that host. A malformed value (containing dots or a full hostname) routes the request — and the bearer token — to an unintended endpoint.

Why it matters: lower risk than externally-controlled input since region is caller-supplied, and the existing base-class code uses the same f"wonderlabs.{region}" pattern (with region derived from the API). Still, normalizing/validating to a short alpha code (e.g. ^[a-z]{2,8}$) before building the hostname costs little and prevents a token-leak-to-wrong-host footgun.

f"wonderlabs.{region}",
4. Truncated multi-byte responses can raise IndexError
switchbot/devices/keypad.py:65-66

get_basic_info indexes _data[1]/_data[2] and get_password_count indexes _data[1].._data[5], but the only guard is the base _get_basic_info filter (which only rejects the exact 1-byte payloads b"\x07"/b"\x00").

Why it matters: a truncated 2-5 byte reply (BLE proxy strip, firmware error state) passes the guard and crashes with IndexError instead of returning None. This is the exact failure class addressed in the device-level basic-info audit (PR #500). Low probability, but a length check (len(_data) >= N) returning None keeps the failure graceful. The hardware = _data[3] if len(_data) > 3 else None guard already shows the intent — extend it to the earlier indices and to get_password_count.

battery = _data[1] & 0x7F
firmware = _data[2] / 10.0

Checklist

  • No hardcoded secrets
  • Input validation at boundaries — suggestion #1, suggestion #2, suggestion #3
  • Error / partial-failure handling — warning #1
  • Robustness against truncated device responses — suggestion #4
  • Tests verify observable behavior, not source text
  • Test style consistent with repo conventions
  • Diff matches PR description

To rebase specific severity levels, mention me: @bluetoothbot rebase critical (fixes 🔴 only), @bluetoothbot rebase important (fixes 🔴 + 🟡), or just @bluetoothbot rebase for all.


Silent Failure Analysis

🟠 **HIGH** — discarded error return on rollback path
switchbot/devices/keypad.py:270-280

Risk: delete_password returns a bool (False on a device-rejected delete) rather than raising, so a failed rollback is never caught by this except — a security-sensitive passcode is left active on the keypad while the caller believes the add was fully rolled back.

try:
    await self.delete_password(assigned_index)
except Exception:
    _LOGGER.exception(
        "Failed to delete passcode from keypad during rollback."
    )

Fix: Check the return value: if not await self.delete_password(assigned_index): _LOGGER.error(...) so a failed rollback is surfaced (log/raise), not silently swallowed.

🟡 **MEDIUM** — silent false return on partial write
switchbot/devices/keypad.py:297-310

Risk: modify_password returns False with no logging when a mid-sequence chunk fails, leaving the passcode partially overwritten on the device and inconsistent with add_password which raises SwitchbotOperationError on the same failure.

for cmd in cmds:
    result = await self._send_command(cmd)
    if not result or result[0] != 0x01:
        return False

Fix: Log the failing command/index before returning, and consider raising SwitchbotOperationError (or documenting the partial-write risk) so callers cannot silently ignore a half-applied change.


Automated review by Kōan (Claude) HEAD=a9f1048 5 min 13s

@bluetoothbot bluetoothbot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking issues found.

  • Orphan passcode when time-window write fails after add succeeds

@deece deece requested a review from bluetoothbot June 29, 2026 10:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants