Skip to content

feat: add heater support#835

Merged
TomerFi merged 36 commits intoTomerFi:devfrom
YogevBokobza:add-heater-support
Feb 2, 2026
Merged

feat: add heater support#835
TomerFi merged 36 commits intoTomerFi:devfrom
YogevBokobza:add-heater-support

Conversation

@YogevBokobza
Copy link
Copy Markdown
Collaborator

@YogevBokobza YogevBokobza commented Mar 24, 2025

Description

Adding heater support.
heater is a new device, token based that acts more or less like Switcher Boiler devices.
This will solve the issue: #844

Checklist

  • I have followed this repository's contributing guidelines.
  • I will adhere to the project's code of conduct.

Additional information

Summary by Sourcery

Add first-class support for Switcher Heater devices alongside existing device types, including discovery, control, and state retrieval.

New Features:

  • Introduce the SwitcherHeater device type and category with validation for correct usage.
  • Expose a new get_heater_state API that returns detailed heater status including timers and power consumption.
  • Extend the control_device API and CLI scripts to operate on token-based heater devices using the generic token command format.
  • Add CLI support and examples for controlling and querying heater devices, including a dedicated get_heater_state command.

Enhancements:

  • Update UDP/TCP parsing and bridge discovery logic to recognize heater broadcasts and compute heater-specific state and power metrics.
  • Clarify documentation around token-based devices and expand supported devices documentation to include heater and token requirements.

Tests:

  • Add unit and integration-style tests for heater datagram parsing, device instantiation, API control and state flows, and CLI behavior.

Summary by CodeRabbit

  • New Features

    • Added Switcher Heater: on/off state, remaining time, auto-shutdown, power/electric metrics, and token-enabled timed on/off control and state retrieval.
    • New "Switcher Heater" option in device type dropdown.
  • Documentation

    • Updated CLI help, usage examples, and supported-devices table to include heater entries and token (-k/--token) options.
  • Tests

    • Expanded tests, fixtures, and test resources for heater parsing, state retrieval, token flows, and error cases.

@pull-request-size pull-request-size bot added the size: m Pull request has 30 to 100 lines label Mar 24, 2025
@YogevBokobza YogevBokobza changed the title Add heater support WIP - Add heater support Mar 24, 2025
@pull-request-size pull-request-size bot added size: l Pull request has 100 to 500 lines and removed size: m Pull request has 30 to 100 lines labels Mar 24, 2025
@codecov
Copy link
Copy Markdown

codecov bot commented Apr 9, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.05%. Comparing base (f5ade6d) to head (9486641).
⚠️ Report is 3 commits behind head on dev.

Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #835      +/-   ##
==========================================
+ Coverage   98.99%   99.05%   +0.05%     
==========================================
  Files          11       11              
  Lines        1292     1370      +78     
==========================================
+ Hits         1279     1357      +78     
  Misses         13       13              
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TomerFi
Copy link
Copy Markdown
Owner

TomerFi commented Apr 10, 2025

@YogevBokobza I unsubscribed to avoid the notifications. Please ping when this is ready for review so I can resubscribe. Thank you.

@YogevBokobza YogevBokobza self-assigned this Apr 10, 2025
@github-actions github-actions bot added the Stale label May 11, 2025
@github-actions github-actions bot closed this May 18, 2025
@TomerFi TomerFi added type: wip Work in progress and removed Stale labels May 18, 2025
@YogevBokobza YogevBokobza reopened this May 19, 2025
@github-actions github-actions bot added the Stale label Jun 19, 2025
@github-actions github-actions bot closed this Jun 26, 2025
@TomerFi
Copy link
Copy Markdown
Owner

TomerFi commented Jun 26, 2025

Looks like something is wrong with the stale action, the type: wip label is exempt, this should not have been labeled stale:
https://github.com/TomerFi/aioswitcher/blob/dev/.github/workflows/stale.yml#L21

@TomerFi TomerFi removed the Stale label Jun 26, 2025
@TomerFi TomerFi reopened this Jun 26, 2025
@github-actions github-actions bot added the Stale label Jul 27, 2025
@github-actions github-actions bot closed this Aug 3, 2025
@TomerFi
Copy link
Copy Markdown
Owner

TomerFi commented Aug 5, 2025

I'm removing the Stale action because it's not working as expected.

@YogevBokobza
Copy link
Copy Markdown
Collaborator Author

YogevBokobza commented Nov 24, 2025

Update:
Almost ready to be Open.
Need to check the functionality of the power consumption and electric current

@YogevBokobza YogevBokobza marked this pull request as ready for review December 21, 2025 23:26
@auto-me-bot auto-me-bot bot added the status: needs review Pull request needs a review label Dec 21, 2025
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai bot commented Feb 2, 2026

Reviewer's Guide

Adds full support for the new token-based Switcher Heater device across the core device model, TCP/UDP protocol handling, high-level API, CLI scripts, documentation, and tests, including heater-specific state parsing and token-based control commands.

Sequence diagram for CLI-based heater control and state retrieval

sequenceDiagram
  actor User
  participant ControlScript as control_device_py
  participant Api as SwitcherApi
  participant Device as HeaterDevice

  User->>ControlScript: invoke get_heater_state -c heater -k token -d id -l key -i ip
  ControlScript->>Api: create SwitcherApi(DeviceType.HEATER, ip, id, key, token)
  ControlScript->>Api: get_heater_state()
  Api->>Api: _login()
  Api->>Device: send GET_STATE_PACKET2_TYPE2
  Device-->>Api: raw_state_response
  Api->>Api: SwitcherHeaterStateResponse(raw_state_response)
  Api-->>ControlScript: SwitcherHeaterStateResponse
  ControlScript-->>User: print heater state, time_left, time_on, auto_shutdown, power, current

  User->>ControlScript: invoke turn_on -c heater -k token -d id -l key -i ip -t 15
  ControlScript->>Api: create SwitcherApi(DeviceType.HEATER, ip, id, key, token)
  ControlScript->>Api: control_device(Command.ON, 15)
  Api->>Api: _login()
  alt token_present
    Api->>Api: build GENERAL_TOKEN_COMMAND with CONTROL_DEVICE_PRECOMMAND and timer
  else no_token
    Api->>Api: build SEND_CONTROL_PACKET with session_id and timer
  end
  Api->>Device: send control packet
  Device-->>Api: control_response
  Api-->>ControlScript: SwitcherBaseResponse
  ControlScript-->>User: print control result
Loading

Class diagram for new heater-related API and device types

classDiagram

class DeviceCategory {
  <<enumeration>>
  HEATER
}

class DeviceType {
  <<enumeration>>
  HEATER
  hex_code : str
  protocol_type : int
  category : DeviceCategory
  token_needed : bool
}

class SwitcherBaseResponse {
  +unparsed_response : bytes
  +successful : bool
}

class SwitcherHeaterStateResponse {
  +state : DeviceState
  +time_left : str
  +time_on : str
  +auto_shutdown : str
  +power_consumption : int
  +electric_current : float
  +__post_init__() void
}

class StateMessageParser {
  +get_heater_state() DeviceState
  +get_heater_time_left() str
  +get_heater_time_on() str
  +get_heater_auto_shutdown() str
  +get_heater_power_consumption() int
}

class SwitcherApi {
  -_device_id : str
  -_device_key : str
  -_device_type : DeviceType
  -_token : str
  +control_device(command : Command, minutes : int) SwitcherBaseResponse
  +get_heater_state() SwitcherHeaterStateResponse
}

class SwitcherBase {
}

class SwitcherTimedBase {
}

class SwitcherPowerBase {
}

class SwitcherHeater {
  +__post_init__() void
}

class BridgeDatagramParser {
  +get_heater_state() DeviceState
  +get_heater_power_consumption() int
  +get_heater_remaining() str
}

class DeviceState {
  <<enumeration>>
  ON
  OFF
}

SwitcherHeaterStateResponse --|> SwitcherBaseResponse
SwitcherApi ..> SwitcherHeaterStateResponse : returns
SwitcherApi ..> SwitcherBaseResponse : returns
SwitcherHeaterStateResponse ..> StateMessageParser : uses
StateMessageParser ..> DeviceState : returns
BridgeDatagramParser ..> DeviceState : returns
DeviceType --> DeviceCategory : has
SwitcherHeater --|> SwitcherTimedBase
SwitcherHeater --|> SwitcherPowerBase
SwitcherHeater --|> SwitcherBase
Loading

Flow diagram for UDP discovery and heater device creation

flowchart LR
  A["UDP datagram received"] --> B["is_switcher_originator()"]; 
  B -->|false| Z["ignore datagram"]
  B -->|true| C["parse datagram with BridgeDatagramParser"]
  C --> D["get_device_type()"]; 
  D --> E{device_type.category == HEATER};
  E -->|no| F["handle other categories (boiler, breeze, runner, light)"]
  F --> G["create appropriate Switcher device"]
  G --> H["device_callback(device)"]

  E -->|yes| I["get_heater_state()"]
  I --> J{state == ON};
  J -->|yes| K["get_heater_power_consumption()"]
  K --> L["electric_current = watts_to_amps(power)"]
  J -->|no| M["power_consumption = 0; electric_current = 0.0"]

  L --> N["prepare heater timings and auto_shutdown"]
  M --> N
  N --> O["create SwitcherHeater(device_type, state, ids, token_needed, power, current, remaining, auto_shutdown)"]
  O --> P["device_callback(heater)"]
Loading

File-Level Changes

Change Details Files
Introduce heater as a first-class device type with its own category and dataclass, and wire it into device discovery.
  • Add DeviceCategory.HEATER and DeviceType.HEATER with correct product code, protocol type, and token requirement.
  • Implement SwitcherHeater dataclass based on timed and power-capable base classes with category validation.
  • Extend bridge discovery logic to recognize heater datagrams, parse heater-specific fields, and instantiate SwitcherHeater with remaining time and auto-shutdown metadata.
  • Update DatagramParser to recognize heater broadcast frames by length and expose heater-specific state, power consumption, and remaining-time accessors.
src/aioswitcher/device/__init__.py
src/aioswitcher/bridge.py
tests/test_device_dataclasses.py
tests/test_device_parsing.py
tests/test_udp_datagram_parsing.py
.github/ISSUE_TEMPLATE/bug_report.yml
Extend the TCP API and message parsing to support heater-specific state retrieval and token-based control commands.
  • Add heater-related getters to StateMessageParser for state, power, time-left, time-on, and auto-shutdown, and use them in a new SwitcherHeaterStateResponse model that also derives electric current.
  • Implement SwitcherApi.get_heater_state using the type-2 state packet, with error handling for failed login or invalid heater state responses.
  • Modify SwitcherApi.control_device to send either the legacy control packet or a new GENERAL_TOKEN_COMMAND variant when a token is supplied, using a new CONTROL_DEVICE_PRECOMMAND constant.
  • Clarify TCP port comments to reflect heater as a type-2 device and add the new precommand constant for token control packets.
src/aioswitcher/api/messages.py
src/aioswitcher/api/__init__.py
src/aioswitcher/api/packets.py
tests/test_api_tcp_client.py
tests/test_device_enum_helpers.py
tests/testresources/dummy_responses/get_heater_state_response.txt
Expose heater control and state retrieval through the CLI and documentation, including token usage and supported-devices metadata.
  • Register the heater device type in scripts/control_device, add a get_heater_state subcommand, and extend turn_on/turn_off code paths to pass an optional token into SwitcherApi.
  • Update CLI help strings and examples to describe token usage for all token-based devices and specifically add heater on/off and state examples.
  • Extend supported devices documentation with a token-required column and add the Switcher Heater entry and product link.
  • Add a usage_api heater excerpt demonstrating heater state and control from Python.
  • Update bug report template to include Switcher Heater in the device list.
scripts/control_device.py
docs/scripts.md
docs/supported.md
docs/usage_api.md
.github/ISSUE_TEMPLATE/bug_report.yml
Expand and refactor tests to cover heater behavior and tokenized control flows.
  • Refactor test_api_tcp_client device-type fixtures to clearer names and add a dedicated heater token fixture.
  • Add tests for heater state retrieval success and failure paths and for token-based timed on/off control via control_device.
  • Add dataclass tests ensuring correct SwitcherHeater instantiation and validation errors for non-heater types.
  • Add UDP datagram parsing tests (on/off) and device-parsing tests to confirm heater discovery and parsing of IP, MAC, name, state, power, remaining time, and auto-shutdown.
  • Introduce dummy heater response and datagram fixtures for state and discovery tests.
tests/test_api_tcp_client.py
tests/test_device_dataclasses.py
tests/test_udp_datagram_parsing.py
tests/test_device_parsing.py
tests/test_device_enum_helpers.py
tests/testresources/dummy_responses/get_heater_state_response.txt
tests/testresources/test_device_parsing/test_a_heater_datagram_produces_device.txt
tests/testresources/test_udp_datagram_parsing/test_datagram_state_off_heater.txt
tests/testresources/test_udp_datagram_parsing/test_datagram_state_on_heater.txt

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 6 issues, and left some high level feedback:

  • In SwitcherApi.control_device, you currently switch to the token-based GENERAL_TOKEN_COMMAND whenever self._token is truthy; to avoid sending a token command to non-token devices, consider gating this on self._device_type.token_needed (and possibly erroring if a token is missing for token-required types).
  • The new heater parsing helpers in StateMessageParser and DatagramParser repeat the same little‑endian hex slicing pattern; factoring out a small utility for converting 4‑byte hex segments to integers/ISO durations would reduce duplication and make the offsets easier to audit.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `SwitcherApi.control_device`, you currently switch to the token-based `GENERAL_TOKEN_COMMAND` whenever `self._token` is truthy; to avoid sending a token command to non-token devices, consider gating this on `self._device_type.token_needed` (and possibly erroring if a token is missing for token-required types).
- The new heater parsing helpers in `StateMessageParser` and `DatagramParser` repeat the same little‑endian hex slicing pattern; factoring out a small utility for converting 4‑byte hex segments to integers/ISO durations would reduce duplication and make the offsets easier to audit.

## Individual Comments

### Comment 1
<location> `src/aioswitcher/api/messages.py:217-221` </location>
<code_context>
+        )
+        return seconds_to_iso_time(auto_off_seconds)
+
+    def get_heater_state(self) -> DeviceState:
+        """Return the current heater device state."""
+        hex_state = self._hex_response[152:154].decode()
+        states = dict(map(lambda s: (s.value, s), DeviceState))
+        return states[hex_state]
+

</code_context>

<issue_to_address>
**suggestion (performance):** Avoid rebuilding the DeviceState lookup dict on every get_heater_state call

Because `DeviceState` is static, this mapping doesn’t need to be rebuilt on every call. Either inline a simple comprehension (`{s.value: s for s in DeviceState}`) or, preferably, define the mapping once at module level or as a cached property and reuse it to avoid repeated allocations.

Suggested implementation:

```python
        )
        return seconds_to_iso_time(auto_off_seconds)


    def get_heater_state(self) -> DeviceState:
        """Return the current heater device state."""
        hex_state = self._hex_response[152:154].decode()
        return DEVICE_STATE_BY_VALUE[hex_state]


    def get_heater_power_consumption(self) -> int:

```

You also need to define the shared mapping once, near where `DeviceState` is defined in this module, for example:

```python
DEVICE_STATE_BY_VALUE: dict[str, DeviceState] = {state.value: state for state in DeviceState}
```

Place this at module level (after the `DeviceState` enum definition is available) so that `get_heater_state` can use it.
</issue_to_address>

### Comment 2
<location> `tests/test_api_tcp_client.py:203-209` </location>
<code_context>
     assert_that(response.unparsed_response).is_equal_to(get_state_response_packet)


+async def test_get_heater_state_function_with_valid_packets(reader_mock, writer_write, connected_api_token_type2_2, resource_path_root):
+    three_packets = _get_dummy_packets(resource_path_root, "login_response", "login2_response", "get_heater_state_response")
+    with patch.object(reader_mock, "read", side_effect=three_packets):
+        response = await connected_api_token_type2_2.get_heater_state()
+    assert_that(writer_write.call_count).is_equal_to(3)
+    assert_that(response).is_instance_of(SwitcherHeaterStateResponse)
+    assert_that(response.unparsed_response).is_equal_to(three_packets[-1])
+
+
</code_context>

<issue_to_address>
**suggestion (testing):** Extend heater state test to assert parsed heater-specific fields, not just type and raw response

This only checks packet count, response type, and raw payload. It doesn’t verify the parsed heater fields introduced in `StateMessageParser` / `SwitcherHeaterStateResponse`. Please also assert the parsed values (e.g., `state`, `time_left`, `time_on`, `auto_shutdown`, `power_consumption`, `electric_current`) against the fixture-based heater response so regressions in offsets/conversion logic are caught.

Suggested implementation:

```python
async def test_get_heater_state_function_with_valid_packets(reader_mock, writer_write, connected_api_token_type2_2, resource_path_root):
    three_packets = _get_dummy_packets(
        resource_path_root,
        "login_response",
        "login2_response",
        "get_heater_state_response",
    )

    with patch.object(reader_mock, "read", side_effect=three_packets):
        response = await connected_api_token_type2_2.get_heater_state()

    assert_that(writer_write.call_count).is_equal_to(3)
    assert_that(response).is_instance_of(SwitcherHeaterStateResponse)
    assert_that(response.unparsed_response).is_equal_to(three_packets[-1])

    # Parsed heater-specific fields
    assert_that(response.state).is_equal_to("on")
    assert_that(response.time_left).has_minutes(44)
    assert_that(response.time_on).has_minutes(16)
    assert_that(response.auto_shutdown).has_hours(1)
    assert_that(response.power_consumption).is_equal_to(2400)
    assert_that(response.electric_current).is_close_to(10.0, within=0.1)

```

1. The helper assertions `has_minutes` / `has_hours` may not exist in your test suite:
   - If they do not exist, replace them with direct comparisons to `datetime.timedelta`, for example:
     - `assert_that(response.time_left).is_equal_to(timedelta(minutes=44))`
     - `assert_that(response.time_on).is_equal_to(timedelta(minutes=16))`
     - `assert_that(response.auto_shutdown).is_equal_to(timedelta(hours=1))`
   - Ensure `from datetime import timedelta` is imported at the top of `tests/test_api_tcp_client.py` if you use `timedelta`.
2. The exact expected values (`"on"`, `44`, `16`, `1`, `2400`, `10.0`) must match the `get_heater_state_response` fixture contents. If your fixture encodes different values, adjust the literals accordingly so the test truly validates the parsed offsets/conversions for that fixture.
</issue_to_address>

### Comment 3
<location> `tests/test_api_tcp_client.py:145-148` </location>
<code_context>
             assert_that(api.connected).is_true()


 async def test_api_with_token_needed_but_missing_should_raise_error():
     with raises(RuntimeError, match="A token is needed but is missing"):
         with patch("aioswitcher.api.open_connection", return_value=b''):
-            await SwitcherApi(device_type_token_api2, device_ip, device_id, device_key, token_empty)
+            await SwitcherApi(device_type_token_runner_s11, device_ip, device_id, device_key, token_empty)


</code_context>

<issue_to_address>
**suggestion (testing):** Add a similar token-missing test for the new HEATER device type

Since `DeviceType.HEATER` is also `token_needed=True` and uses the same token-based TCP flow, please add a corresponding test that instantiates `SwitcherApi` with `DeviceType.HEATER` and an empty token and asserts the same `RuntimeError`. You can either duplicate this test for the heater or parametrize it over `[DeviceType.RUNNER_S11, DeviceType.HEATER]` so both are covered.

Suggested implementation:

```python
import pytest
from pytest import raises

```

```python
@pytest.mark.parametrize(
    "device_type",
    [device_type_token_runner_s11, device_type_token_heater],
)
async def test_api_with_token_needed_but_missing_should_raise_error(device_type):
    with raises(RuntimeError, match="A token is needed but is missing"):
        with patch("aioswitcher.api.open_connection", return_value=b''):
            await SwitcherApi(device_type, device_ip, device_id, device_key, token_empty)

```

If `import pytest` already exists in this file, remove the duplicate import and keep a single `import pytest` at the top. No other changes should be necessary, since `device_type_token_heater` is already defined at the bottom of the file.
</issue_to_address>

### Comment 4
<location> `docs/scripts.md:173` </location>
<code_context>
                         the type of the device
   -k TOKEN, --token TOKEN
-                        the token for communicating with the new switcher devices
+                        the token for communicating with switcher token based devices
   -d DEVICE_ID, --device-id DEVICE_ID
                         the identification of the device
</code_context>

<issue_to_address>
**suggestion (typo):** Consider hyphenating "token-based" in this phrase for correct grammar.

As it’s used as a compound adjective here, it should take a hyphen; please update all occurrences in this file for consistency.

Suggested implementation:

```
  -k TOKEN, --token TOKEN
                        the token for communicating with switcher token-based devices

```

```
  -k TOKEN, --token TOKEN
                        the token for communicating with switcher token-based devices

```
</issue_to_address>

### Comment 5
<location> `docs/supported.md:1` </location>
<code_context>
-| Switcher Light SL02      |   [product][switcher-light-sl02]    |         4.3.x         |
-| Switcher Light SL02 Mini | [product][switcher-light-sl02-mini] |         4.3.x         |
-| Switcher Light SL03      |   [product][switcher-light-sl03]    |         4.4.x         |
+| Name                     |                Link                 | Included from version | Is require a token |
+|--------------------------|:-----------------------------------:|:---------------------:|:------------------:|
+| Switcher V2              |       [product][switcher-v2]        |         1.x.x         |         No         |
</code_context>

<issue_to_address>
**issue (typo):** Column header "Is require a token" is ungrammatical; consider rephrasing.

Consider alternatives like "Requires token" or "Token required" for a clearer, grammatically correct header.

```suggestion
| Name                     |                Link                 | Included from version | Requires token     |
```
</issue_to_address>

### Comment 6
<location> `docs/usage_api.md:155` </location>
<code_context>
+async def control_heater(device_type, device_ip, device_id, device_key, token) :
+    # for connecting to a device we need its type, id, login key, ip address and token
+    async with SwitcherApi(device_type, device_ip, device_id, device_key, token) as api:
+        # get the device current state
+        await api.get_heater_state()
+        # turn the device on for 15 minutes
</code_context>

<issue_to_address>
**nitpick (typo):** Minor grammar tweak: "device current state" → "device's current state".

You could also adjust the verb to "get the device's current state" for smoother wording.

```suggestion
        # get the device's current state
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +217 to +221
def get_heater_state(self) -> DeviceState:
"""Return the current heater device state."""
hex_state = self._hex_response[152:154].decode()
states = dict(map(lambda s: (s.value, s), DeviceState))
return states[hex_state]
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.

suggestion (performance): Avoid rebuilding the DeviceState lookup dict on every get_heater_state call

Because DeviceState is static, this mapping doesn’t need to be rebuilt on every call. Either inline a simple comprehension ({s.value: s for s in DeviceState}) or, preferably, define the mapping once at module level or as a cached property and reuse it to avoid repeated allocations.

Suggested implementation:

        )
        return seconds_to_iso_time(auto_off_seconds)


    def get_heater_state(self) -> DeviceState:
        """Return the current heater device state."""
        hex_state = self._hex_response[152:154].decode()
        return DEVICE_STATE_BY_VALUE[hex_state]


    def get_heater_power_consumption(self) -> int:

You also need to define the shared mapping once, near where DeviceState is defined in this module, for example:

DEVICE_STATE_BY_VALUE: dict[str, DeviceState] = {state.value: state for state in DeviceState}

Place this at module level (after the DeviceState enum definition is available) so that get_heater_state can use it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

???

Comment on lines +203 to +209
async def test_get_heater_state_function_with_valid_packets(reader_mock, writer_write, connected_api_token_type2_2, resource_path_root):
three_packets = _get_dummy_packets(resource_path_root, "login_response", "login2_response", "get_heater_state_response")
with patch.object(reader_mock, "read", side_effect=three_packets):
response = await connected_api_token_type2_2.get_heater_state()
assert_that(writer_write.call_count).is_equal_to(3)
assert_that(response).is_instance_of(SwitcherHeaterStateResponse)
assert_that(response.unparsed_response).is_equal_to(three_packets[-1])
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.

suggestion (testing): Extend heater state test to assert parsed heater-specific fields, not just type and raw response

This only checks packet count, response type, and raw payload. It doesn’t verify the parsed heater fields introduced in StateMessageParser / SwitcherHeaterStateResponse. Please also assert the parsed values (e.g., state, time_left, time_on, auto_shutdown, power_consumption, electric_current) against the fixture-based heater response so regressions in offsets/conversion logic are caught.

Suggested implementation:

async def test_get_heater_state_function_with_valid_packets(reader_mock, writer_write, connected_api_token_type2_2, resource_path_root):
    three_packets = _get_dummy_packets(
        resource_path_root,
        "login_response",
        "login2_response",
        "get_heater_state_response",
    )

    with patch.object(reader_mock, "read", side_effect=three_packets):
        response = await connected_api_token_type2_2.get_heater_state()

    assert_that(writer_write.call_count).is_equal_to(3)
    assert_that(response).is_instance_of(SwitcherHeaterStateResponse)
    assert_that(response.unparsed_response).is_equal_to(three_packets[-1])

    # Parsed heater-specific fields
    assert_that(response.state).is_equal_to("on")
    assert_that(response.time_left).has_minutes(44)
    assert_that(response.time_on).has_minutes(16)
    assert_that(response.auto_shutdown).has_hours(1)
    assert_that(response.power_consumption).is_equal_to(2400)
    assert_that(response.electric_current).is_close_to(10.0, within=0.1)
  1. The helper assertions has_minutes / has_hours may not exist in your test suite:
    • If they do not exist, replace them with direct comparisons to datetime.timedelta, for example:
      • assert_that(response.time_left).is_equal_to(timedelta(minutes=44))
      • assert_that(response.time_on).is_equal_to(timedelta(minutes=16))
      • assert_that(response.auto_shutdown).is_equal_to(timedelta(hours=1))
    • Ensure from datetime import timedelta is imported at the top of tests/test_api_tcp_client.py if you use timedelta.
  2. The exact expected values ("on", 44, 16, 1, 2400, 10.0) must match the get_heater_state_response fixture contents. If your fixture encodes different values, adjust the literals accordingly so the test truly validates the parsed offsets/conversions for that fixture.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

???

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.

I was suggesting one more assertion layer for that new heater test, not pointing out a bug.

Right now this test only checks:

  • we sent 3 packets
  • we got a SwitcherHeaterStateResponse
  • the raw payload matches the last dummy packet

Because heater support adds new parsed fields (state, time_left, power, etc.), it’s useful to also assert that those parsed values match what’s actually encoded in get_heater_state_response.txt. That way, if someone later breaks the parsing offsets or unit conversions, this test will fail.

Concretely, I’d recommend extending the test like this (using whatever values are actually in your fixture):

async def test_get_heater_state_function_with_valid_packets(
    reader_mock,
    writer_write,
    connected_api_token_type2_2,
    resource_path_root,
):
    three_packets = _get_dummy_packets(
        resource_path_root,
        "login_response",
        "login2_response",
        "get_heater_state_response",
    )

    with patch.object(reader_mock, "read", side_effect=three_packets):
        response = await connected_api_token_type2_2.get_heater_state()

    assert_that(writer_write.call_count).is_equal_to(3)
    assert_that(response).is_instance_of(SwitcherHeaterStateResponse)
    assert_that(response.unparsed_response).is_equal_to(three_packets[-1])

    # Also validate parsed heater values from the fixture
    # NOTE: adjust these to match what get_heater_state_response.txt encodes
    assert_that(response.state).is_equal_to("on")
    assert_that(response.time_left).is_equal_to(timedelta(minutes=44))
    assert_that(response.time_on).is_equal_to(timedelta(minutes=16))
    assert_that(response.auto_shutdown).is_equal_to(timedelta(hours=1))
    assert_that(response.power_consumption).is_equal_to(2400)
    assert_that(response.electric_current).is_close_to(10.0, within=0.1)

If the fixture encodes different values, just change the literals accordingly. If you prefer to keep the test minimal and consistent with the existing test_get_state_function_with_valid_packets, I’m fine with that too; this was just a suggestion to make heater parsing a bit more strongly covered by tests.

Happy to adjust or drop the suggestion if it doesn’t fit your testing style for this project.

Comment on lines 145 to +148
async def test_api_with_token_needed_but_missing_should_raise_error():
with raises(RuntimeError, match="A token is needed but is missing"):
with patch("aioswitcher.api.open_connection", return_value=b''):
await SwitcherApi(device_type_token_api2, device_ip, device_id, device_key, token_empty)
await SwitcherApi(device_type_token_runner_s11, device_ip, device_id, device_key, token_empty)
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.

suggestion (testing): Add a similar token-missing test for the new HEATER device type

Since DeviceType.HEATER is also token_needed=True and uses the same token-based TCP flow, please add a corresponding test that instantiates SwitcherApi with DeviceType.HEATER and an empty token and asserts the same RuntimeError. You can either duplicate this test for the heater or parametrize it over [DeviceType.RUNNER_S11, DeviceType.HEATER] so both are covered.

Suggested implementation:

import pytest
from pytest import raises
@pytest.mark.parametrize(
    "device_type",
    [device_type_token_runner_s11, device_type_token_heater],
)
async def test_api_with_token_needed_but_missing_should_raise_error(device_type):
    with raises(RuntimeError, match="A token is needed but is missing"):
        with patch("aioswitcher.api.open_connection", return_value=b''):
            await SwitcherApi(device_type, device_ip, device_id, device_key, token_empty)

If import pytest already exists in this file, remove the duplicate import and keep a single import pytest at the top. No other changes should be necessary, since device_type_token_heater is already defined at the bottom of the file.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

???

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.

My earlier comment was suggesting an extra test, not indicating that something is broken.

Right now there’s a test that checks the runner S11 token device fails when the token is missing:

async def test_api_with_token_needed_but_missing_should_raise_error():
    with raises(RuntimeError, match="A token is needed but is missing"):
        with patch("aioswitcher.api.open_connection", return_value=b''):
            await SwitcherApi(device_type_token_runner_s11, device_ip, device_id, device_key, token_empty)

The new heater device type (device_type_token_heater = DeviceType.HEATER) also requires a token, so the idea is to verify the same behavior for it. There are two options:

  1. Parametrize the existing test to cover both devices (preferred, less duplication):
@pytest.mark.parametrize(
    "device_type",
    [device_type_token_runner_s11, device_type_token_heater],
)
async def test_api_with_token_needed_but_missing_should_raise_error(device_type):
    with raises(RuntimeError, match="A token is needed but is missing"):
        with patch("aioswitcher.api.open_connection", return_value=b''):
            await SwitcherApi(device_type, device_ip, device_id, device_key, token_empty)
  1. Or add a second, heater-specific test that mirrors the existing one for runner S11.

If you’d prefer, I can adapt the suggestion to fit your testing style (e.g., keep tests un-parametrized, different naming, etc.).

@auto-me-bot auto-me-bot bot added status: review started Pull review in progress and removed status: approved Pull request is approved labels Feb 2, 2026
TomerFi
TomerFi previously approved these changes Feb 2, 2026
@auto-me-bot auto-me-bot bot added status: approved Pull request is approved and removed status: review started Pull review in progress labels Feb 2, 2026
@TomerFi TomerFi force-pushed the add-heater-support branch from fdee4af to 9486641 Compare February 2, 2026 19:38
@auto-me-bot auto-me-bot bot added status: review started Pull review in progress and removed status: approved Pull request is approved labels Feb 2, 2026
@TomerFi
Copy link
Copy Markdown
Owner

TomerFi commented Feb 2, 2026

@YogevBokobza Looks like the AI is going crazy today, Sourcery finally reported stuff and my local Claude tried to fix on my behalf. I forced pushed, returning it to your original commit, can you please address Sourcery's comments?

@YogevBokobza
Copy link
Copy Markdown
Collaborator Author

@YogevBokobza Looks like the AI is going crazy today, Sourcery finally reported stuff and my local Claude tried to fix on my behalf. I forced pushed, returning it to your original commit, can you please address Sourcery's comments?

I come back in my opinion.. this AI stuff makes me work harder than actually improve.. :|

YogevBokobza and others added 3 commits February 2, 2026 22:32
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Signed-off-by: YogevBokobza <yogevbokobza12@gmail.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Signed-off-by: YogevBokobza <yogevbokobza12@gmail.com>
@YogevBokobza
Copy link
Copy Markdown
Collaborator Author

@TomerFi
Those AI suggestions are very much nitpicking and in a sake of "just to change something"..
I dont think need to break old logics just for the AI feel good with itself..
Stuff can be improved over other PRs as refactoring PRs but it cant stale PRs that proved to be working in other places..
Espacially since you and @thecode avaiablity is not high and I cant bother you every small change the AI suggest to do..

@TomerFi
Copy link
Copy Markdown
Owner

TomerFi commented Feb 2, 2026

Let's merge then.

@auto-me-bot auto-me-bot bot added status: approved Pull request is approved and removed status: review started Pull review in progress labels Feb 2, 2026
@TomerFi TomerFi disabled auto-merge February 2, 2026 20:55
@TomerFi TomerFi merged commit f40fca6 into TomerFi:dev Feb 2, 2026
9 of 10 checks passed
@auto-me-bot auto-me-bot bot added status: merged Pull request merged and removed status: approved Pull request is approved labels Feb 2, 2026
@TomerFi
Copy link
Copy Markdown
Owner

TomerFi commented Feb 3, 2026

@YogevBokobza @thecode Thank you both, this was included in release 6.1.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size: l Pull request has 100 to 500 lines Stale status: merged Pull request merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants