Skip to content

fix: guard device get_basic_info parsers against short responses#500

Draft
bluetoothbot wants to merge 6 commits into
sblibs:mainfrom
bluetoothbot:koan/device-basic-info-guards
Draft

fix: guard device get_basic_info parsers against short responses#500
bluetoothbot wants to merge 6 commits into
sblibs:mainfrom
bluetoothbot:koan/device-basic-info-guards

Conversation

@bluetoothbot

@bluetoothbot bluetoothbot commented May 17, 2026

Copy link
Copy Markdown
Collaborator

What

Adds length guards to every device-class get_basic_info() (and a few related response parsers) so a truncated BLE reply returns None instead of raising IndexError.

Why

The base _get_basic_info() only filters single-byte error replies (b\"\x07\", b\"\x00\"). A 2-byte non-matching reply (e.g. b\"\x02\x00\" from firmware error states, or a payload truncated by a flaky BLE proxy) slips through and crashes the device-level parser when it indexes past the buffer end (e.g. bot.get_basic_info reads _data[10]).

This is the command-response counterpart to the adv_parsers audit (#494 / #495 / #496) — same class of bug, different layer.

How

One-liner length guards in each device's get_basic_info, sized to max_index + 1:

device max index min len
bot _data[10] 11
bulb _data[10] 11 (+ version_info ≥ 3)
ceiling_light _data[3:5] 5 (+ version_info ≥ 3)
fan _data[9] 10 (+ _data1 ≥ 3)
keypad_vision get_basic_info _data[14] 15
keypad_vision get_password_count _data[5/7] 6 (Vision) / 8 (Pro)
air_purifier _data[15] 16 (+ led_settings ≥ 6, led_status ≥ 2)
blind_tilt _data[7] 8
curtain _data[7] 8
evaporative_humidifier _data[10] 11
light_strip _data[10] 11
candle_warmer_lamp _data[2] 3
roller_shade _data[6] 7
smart_thermostat_radiator _data[14] 15
vacuum _data[2] 3
art_frame _data[6] + dynamic image-count guard 7+

Also tightens three short-circuits that used the same brittle (b\"\x07\", b\"\x00\") membership test:

  • fan._get_basic_infolen <= 1
  • blind_tilt.get_extended_info_summarylen < 2
  • curtain.get_extended_info_summarylen < 3

Testing

  • 46 new tests in tests/test_device_basic_info_guards.py covering every patched function with short payloads.
  • Full suite: 1259 passed.
  • tests/test_art_frame.py next/prev/set_image tests updated to mock _get_current_image_index directly — they previously relied on a bare AsyncMock slipping past the length-blind parser (same trap as PR fix: guard relay_switch parsers against short payloads (#369) #492).

🤖 Generated with Claude Code


Quality Report

Changes: 17 files changed, 383 insertions(+), 5 deletions(-)

Code scan: clean

Tests: passed (1264 passed)

Branch hygiene: clean

Generated by Kōan post-mission quality pipeline

@codecov

codecov Bot commented May 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
switchbot/devices/air_purifier.py 100.00% <100.00%> (ø)
switchbot/devices/art_frame.py 100.00% <100.00%> (ø)
switchbot/devices/base_cover.py 100.00% <100.00%> (ø)
switchbot/devices/blind_tilt.py 98.68% <100.00%> (+0.03%) ⬆️
switchbot/devices/bot.py 50.74% <100.00%> (+6.13%) ⬆️
switchbot/devices/bulb.py 100.00% <100.00%> (ø)
switchbot/devices/ceiling_light.py 100.00% <100.00%> (ø)
switchbot/devices/curtain.py 98.71% <100.00%> (+0.03%) ⬆️
switchbot/devices/evaporative_humidifier.py 100.00% <100.00%> (ø)
switchbot/devices/fan.py 100.00% <100.00%> (ø)
... and 5 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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

This PR hardens device-level get_basic_info() (and a few related response parsers) so truncated/short BLE replies return None instead of raising IndexError, aligning robustness with the recent advertisement parser guard sweep.

Changes:

  • Added minimum-length guards across multiple device get_basic_info() implementations (plus a few related parsers) to avoid indexing past short responses.
  • Tightened several “unsuccessful reply” short-circuits from specific-byte checks to general length-based checks.
  • Added a new regression test suite covering short-payload behavior across all patched devices, and updated Art Frame command tests to avoid relying on parsing side effects.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/test_device_basic_info_guards.py New regression tests ensuring short responses return None instead of raising.
tests/test_art_frame.py Adjusts Art Frame command tests to mock _get_current_image_index directly.
switchbot/devices/vacuum.py Adds a minimum-length guard before indexing firmware byte.
switchbot/devices/smart_thermostat_radiator.py Adds a minimum-length guard before parsing thermostat payload fields.
switchbot/devices/roller_shade.py Adds a minimum-length guard before parsing roller shade fields.
switchbot/devices/light_strip.py Adds guards for both _data and _version_info in light strip + candle warmer lamp basic info parsing.
switchbot/devices/keypad_vision.py Adds minimum-length guards for basic info and password count parsing (Vision vs Pro).
switchbot/devices/fan.py Adds length guards in get_basic_info and broadens _get_basic_info failure detection to len <= 1.
switchbot/devices/evaporative_humidifier.py Adds a minimum-length guard before parsing humidifier settings.
switchbot/devices/curtain.py Adds a minimum-length guard for get_basic_info and tightens short-response handling in get_extended_info_summary.
switchbot/devices/ceiling_light.py Adds guards for _data and _version_info before parsing.
switchbot/devices/bulb.py Adds guards for _data and _version_info before parsing.
switchbot/devices/bot.py Adds a minimum-length guard before parsing bot basic info offsets.
switchbot/devices/blind_tilt.py Adds a minimum-length guard for get_basic_info and tightens short-response handling in get_extended_info_summary.
switchbot/devices/base_cover.py Tightens short-response handling and avoids indexing into grouped-device data unless length permits.
switchbot/devices/art_frame.py Adds a minimum-length guard plus a dynamic guard for all_images_index based on claimed image count.
switchbot/devices/air_purifier.py Adds guards for _data, led_settings, and led_status before parsing.

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


# If grouped curtain device present.
if _data[4]:
if len(_data) >= 7 and _data[4]:
@bluetoothbot

Copy link
Copy Markdown
Collaborator Author

PR Review — fix: guard device get_basic_info parsers against short responses

Correct, well-tested hardening sweep — but the branch is stale and fan.py conflicts with merged work.

Strengths:

  • Guard sizes are right. Spot-checked against actual max indices: bot _data[10]→11, keypad_vision basic _data[14]→15, password_count Vision _data[5]→6 / Pro _data[7]→8, ceiling_light _data[3:5]→5. All match.
  • base_cover device1 short-circuit len(_data) >= 7 and _data[4] correctly avoids the IndexError while still parsing the single-device case — and there's a dedicated test for the 6-byte truncated-device1 case.
  • art_frame's dynamic len(_data) < 7 + total_num_of_images guard closes the variable-length overrun, and the test_art_frame.py AsyncMock fix correctly addresses the same trap noted in fix: guard relay_switch parsers against short payloads (#369) #492.
  • 46 focused regression tests, parametrized across boundary lengths.

Needs attention:

  • fan.py will merge-conflict: master merged Add Standing Fan controls and oscillation angle read-back #522 which extracted parsing into _parse_basic_info; the diff still patches the old inline body. Rebase and re-place the guard before the _parse_basic_info call.
  • base_cover stateOfCharge lookup can still IndexError on an out-of-range charge byte (Copilot's point) — in scope given the PR's malformed-payload framing.

🟡 Important

1. Branch is stale — fan.py conflicts with merged #522 refactor
switchbot/devices/fan.py:69-74

This branch's merge-base is behind current master. Since the branch was cut, master (HEAD 7b7b8f1) merged the Standing Fan PR (#522), which refactored fan.get_basic_info: the inline field parsing (_data[2], _data[8], _data[9], _data1[2]) was extracted into a new _parse_basic_info(_data, _data1) method, so get_basic_info now ends with return self._parse_basic_info(_data, _data1).

This diff inserts the guard into the old inline body that no longer exists on master, so fan.py will produce a merge conflict.

Why it matters: the PR can't merge cleanly, and a sloppy conflict resolution risks dropping the guard or leaving it in a dead spot. The fix is mechanical — after rebasing onto current master, place if len(_data) < 10 or len(_data1) < 3: return None right before the return self._parse_basic_info(...) call (both buffers are in scope there). Re-run the suite after rebase; other touched files (ceiling_light.py, bot.py, etc.) still match their base context and merge cleanly — fan.py is the only structural conflict.

return self._parse_basic_info(_data, _data1)  # master; diff still targets the old inline body
2. stateOfCharge lookup can still IndexError on a malformed (in-range-length) reply
switchbot/devices/base_cover.py:97-105

The new length guards prevent indexing past the buffer, but stateOfCharge is decoded as a list lookup: _state_of_charge[_data[3]] (device0, line 97) and _state_of_charge[_data[6]] (device1, line 105). _state_of_charge has 6 entries, so any byte value > 5 raises IndexError even when the payload is the correct length.

This is exactly the failure class the PR targets: a firmware error state or a corrupted-but-full-length BLE reply can carry an out-of-range charge code. The length guard (len(_data) < 4 / >= 7) does not cover it. Copilot flagged the same thing.

This is pre-existing (the lookup lines aren't added by this diff), so it isn't a regression — but the PR changes the adjacent lines and explicitly claims to harden against "firmware error states" and truncated replies, so closing it here is in scope. Suggested fix: bounds-check the index, e.g. _state_of_charge[_data[3]] if _data[3] < len(_state_of_charge) else None (or a guard that returns None), and add a test with _data[3] = 0xFF.

"stateOfCharge": _state_of_charge[_data[3]],  # IndexError if _data[3] > 5

Checklist

  • Guard lengths match accessed indices
  • Branch merges cleanly onto target — warning #1
  • Hardens all reachable crash paths on malformed replies — warning #2
  • Edge case / boundary test coverage
  • Tests verify behavior, not source text
  • No backward-incompatible API change (None on short reply is already the contract)

Automated review by Kōan (Claude) HEAD=53db9ff 2 min 58s

bluetoothbot and others added 5 commits June 20, 2026 15:09
Each device's get_basic_info() reads fixed byte offsets in the command
response. The base _get_basic_info() only filters single-byte error
replies (b"\x07", b"\x00"); a truncated payload from a flaky BLE proxy
or device firmware error slips through and raises IndexError when the
device-level parser indexes past the buffer end.

Mirrors the adv_parsers audit pattern (sblibs#494/sblibs#495/sblibs#496) at the
command-response layer:
  bot           _data[10]  → len >= 11
  bulb          _data[10]  → len >= 11, version_info >= 3
  ceiling_light _data[3:5] → len >= 5,  version_info >= 3
  fan           _data[9]   → len >= 10, version_info >= 3
                            + fan._get_basic_info: tighten filter
                              from {b"\x07", b"\x00"} to len <= 1
  keypad_vision _data[14]  → len >= 15
                _data[5/7] → password_count >= 6 (Vision) / >= 8 (Pro)
  air_purifier  _data[15]  → len >= 16, led_settings >= 6, led_status >= 2
  blind_tilt    _data[7]   → len >= 8
                + get_extended_info_summary: tighten filter
  curtain       _data[7]   → len >= 8
                + get_extended_info_summary: tighten filter
  evap_humid.   _data[10]  → len >= 11
  light_strip   _data[10]  → len >= 11, version_info >= 3
  candle_lamp   _data[2]   → len >= 3,  version_info >= 3
  roller_shade  _data[6]   → len >= 7
  smart_therm.  _data[14]  → len >= 15
  vacuum        _data[2]   → len >= 3
  art_frame     _data[6]   → len >= 7 + dynamic guard against
                              total_num_of_images overrunning buffer

Adds 46 regression tests in test_device_basic_info_guards.py. Also
patches test_art_frame next/prev/set_image tests to mock
_get_current_image_index directly — they previously relied on a bare
AsyncMock slipping past the old length-blind parsers (per the same
trap noted in PR sblibs#492).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same pattern as the rest of this PR. SwitchbotBaseCover.get_extended_info_adv
indexes _data[3] for device0 and _data[6] for the chained-device block, but
only filters single-byte error replies (b"\x07", b"\x00"). A 2-3 byte
truncated reply (BLE proxy strip, firmware error) IndexErrors on _data[3];
a 5-6 byte reply with _data[4] set IndexErrors on _data[5]/_data[6].

Tightens the filter to len < 4 (covers any unparseable device0 payload)
and gates the device1 block on len >= 7 so a truncated chain reply parses
device0 and skips device1 instead of crashing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bluetoothbot

Copy link
Copy Markdown
Collaborator Author

Rebase with requested adjustments

Branch koan/device-basic-info-guards was rebased onto master and review feedback was applied.

Changes applied

  • Done.
  • Summary

  • Bounds-checked stateOfCharge lookup in base_cover.get_extended_info_adv (per @Copilot and Kōan review Beta #2): _state_of_charge[_data[3]] (device0) and _state_of_charge[_data[6]] (device1) raised IndexError on a full-length-but-malformed reply where the charge byte exceeds 5 (firmware error state / corrupted payload). Both lookups now return None when the index is out of range, matching the PR's malformed-payload hardening goal.
  • Added regression test test_curtain_get_extended_info_adv_out_of_range_charge_returns_none: a full-length reply with charge bytes 0xFF returns a result with stateOfCharge: None instead of raising.
  • fan.py warning press #1 (merge conflict with Add Standing Fan controls and oscillation angle read-back #522): already resolved on the current branch — the guard if len(_data) < 10 or len(_data1) < 3: return None sits before the return self._parse_basic_info(_data, _data1) call. No change needed.

Stats

17 files changed, 407 insertions(+), 7 deletions(-)
Actions performed
  • Already-solved check: negative (confidence=high, reasoning=Commit 7b7b8f1 only guarded relay_switch's get_basic_info, while this PR adds short-response guards )
  • Rebased koan/device-basic-info-guards onto upstream/master
  • Applied review feedback
  • Pre-push CI check: previous run passed
  • Force-pushed koan/device-basic-info-guards to origin
  • CI check enqueued in ## CI (async)

CI status

CI will be checked asynchronously.


Automated by Kōan

@bluetoothbot bluetoothbot force-pushed the koan/device-basic-info-guards branch from 53db9ff to 1fd8a46 Compare June 20, 2026 15:09
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.

2 participants