From a07fff181441910936eb08c2ae689f6da3644234 Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Wed, 31 Dec 2025 12:18:17 +0100 Subject: [PATCH 01/21] Adds a class to control SwitchBot Meter Pro (CO2-Monitor) device. --- switchbot/__init__.py | 2 ++ switchbot/devices/meter.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/switchbot/__init__.py b/switchbot/__init__.py index a9d0a352..8db2651d 100644 --- a/switchbot/__init__.py +++ b/switchbot/__init__.py @@ -51,6 +51,7 @@ SwitchbotStripLight3, ) from .devices.lock import SwitchbotLock +from .devices.meter import SwitchbotMeterProCO2 from .devices.plug import SwitchbotPlugMini from .devices.relay_switch import ( SwitchbotGarageDoorOpener, @@ -99,6 +100,7 @@ "SwitchbotHumidifier", "SwitchbotLightStrip", "SwitchbotLock", + "SwitchbotMeterProCO2", "SwitchbotModel", "SwitchbotModel", "SwitchbotOperationError", diff --git a/switchbot/devices/meter.py b/switchbot/devices/meter.py index 9d48db4f..5c5202ee 100644 --- a/switchbot/devices/meter.py +++ b/switchbot/devices/meter.py @@ -1 +1,4 @@ -from __future__ import annotations +from .device import REQ_HEADER, SwitchbotDevice + +class SwitchbotMeterProCO2(SwitchbotDevice): + None \ No newline at end of file From 22bcda05cac9c2cf5702fbf142ed3be9e3622ec5 Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Thu, 1 Jan 2026 00:44:35 +0100 Subject: [PATCH 02/21] Implement get- and set- time offset API. --- switchbot/devices/meter.py | 73 +++++++++++++++++++++++++++++- tests/test_meter.py | 91 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 tests/test_meter.py diff --git a/switchbot/devices/meter.py b/switchbot/devices/meter.py index 5c5202ee..8702c8e4 100644 --- a/switchbot/devices/meter.py +++ b/switchbot/devices/meter.py @@ -1,4 +1,73 @@ -from .device import REQ_HEADER, SwitchbotDevice +from .device import SwitchbotDevice, SwitchbotOperationError + class SwitchbotMeterProCO2(SwitchbotDevice): - None \ No newline at end of file + """API to control Switchbot Meter Pro CO2.""" + + """ + Command code to set the displayed time offse, which happens whenever you + manually set the device display time in the Switchbot app. + + The displayed time is calculated as the internal device time (usually comes + from the factory settings or set by the Switchbot app upon syncing) + + offset. The offset is provided in seconds and can be + positive or negative. + """ + COMMAND_SET_TIME_OFFSET = "570f680506" + COMMAND_GET_TIME_OFFSET = "570f690506" + MAX_TIME_OFFSET = 1 << 24 - 1 + + async def get_time_offset(self) -> int: + """ + Get the current display time offset from the device. + + Returns: + int: The time offset in seconds. Max 24 bits. + """ + # Response Format: 5 bytes, where + # - byte 0: "01" (success) + # - byte 1: "00" (plus offset) or "80" (minus offset) + # - bytes 2-4: int24, number of seconds to offset. + # Example response: 01-80-00-10-00 -> subtract 4096 seconds. + result = await self._send_command(self.COMMAND_GET_TIME_OFFSET) + result = self._validate_result( + 'get_time_offset', result, min_length=5) + + is_negative = result[1] == 0x80 + offset = (result[2] << 16) + (result[3] << 8) + result[4] + return -offset if is_negative else offset + + async def set_time_offset(self, offset_seconds: int): + """ + Set the display time offset on the device. + This is what happens when you adjust display time in the Switchbot app. + + Args: + offset_seconds (int): 2^24 maximum, can be negative. + """ + abs_offset = abs(offset_seconds) + if abs_offset > self.MAX_TIME_OFFSET: + raise SwitchbotOperationError( + f"{self.name}: Requested to set_time_offset of {offset_seconds} seconds, allowed +-{self.MAX_TIME_OFFSET} max." + ) + + sign_byte = "80" if offset_seconds < 0 else "00" + + # Example: 57-0f-68-05-06-80-00-10-00 -> subtract 4096 seconds. + payload = self.COMMAND_SET_TIME_OFFSET + \ + sign_byte + f"{abs_offset:06x}" + result = await self._send_command(payload) + + self._validate_result('set_time_offset', result) + + def _validate_result(self, op_name: str, result: bytes | None, min_length: int | None = None) -> bytes: + if not self._check_command_result(result, 0, {1}): + raise SwitchbotOperationError( + f"{self.name}: Unexpected response code for {op_name} (result={result.hex() if result else "None"} rssi={self.rssi})" + ) + assert result is not None + if min_length is not None and len(result) < min_length: + raise SwitchbotOperationError( + f"{self.name}: Unexpected response len for {op_name}, wanted at least {min_length} (result={result.hex() if result else "None"} rssi={self.rssi})" + ) + return result diff --git a/tests/test_meter.py b/tests/test_meter.py new file mode 100644 index 00000000..65b4111c --- /dev/null +++ b/tests/test_meter.py @@ -0,0 +1,91 @@ +from unittest.mock import AsyncMock + +import pytest +from bleak.backends.device import BLEDevice + +from datetime import datetime +from switchbot import SwitchbotOperationError +from switchbot.devices.meter import SwitchbotMeterProCO2 + + +class TestSwitchbotMeterProCO2: + def create_device(self): + ble_device = BLEDevice(address="aa:bb:cc:dd:ee:ff", + name="any", details={"rssi": -80}) + device = SwitchbotMeterProCO2(ble_device) + device._send_command = AsyncMock() + return device + + @pytest.mark.asyncio + async def test_get_time_offset_positive(self): + device = self.create_device() + # Mock response: 01 (success) 00 (plus offset) 10 1b c9 (1055689 seconds) + device._send_command.return_value = bytes.fromhex("0100101bc9") + + offset = await device.get_time_offset() + device._send_command.assert_called_with("570f690506") + assert offset == 1055689 + + @pytest.mark.asyncio + async def test_get_time_offset_negative(self): + device = self.create_device() + # Mock response: 01 (success) 80 (minus offset) 10 1b c9 (1055689 seconds) + device._send_command.return_value = bytes.fromhex("0180101bc9") + + offset = await device.get_time_offset() + device._send_command.assert_called_with("570f690506") + assert offset == -1055689 + + @pytest.mark.asyncio + async def test_get_time_offset_failure(self): + device = self.create_device() + # Invalid 1st byte + device._send_command.return_value = bytes.fromhex("0080101bc9") + + with pytest.raises(SwitchbotOperationError): + await device.get_time_offset() + device._send_command.assert_called_with("570f690506") + + @pytest.mark.asyncio + async def test_get_time_offset_wrong_response(self): + device = self.create_device() + # Invalid 1st byte + device._send_command.return_value = bytes.fromhex("01") + + with pytest.raises(SwitchbotOperationError): + await device.get_time_offset() + + @pytest.mark.asyncio + async def test_set_time_offset_positive(self): + device = self.create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_time_offset(1055689) + device._send_command.assert_called_with("570f68050600101bc9") + + @pytest.mark.asyncio + async def test_set_time_offset_negative(self): + device = self.create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_time_offset(-4096) + device._send_command.assert_called_with("570f68050680001000") + + @pytest.mark.asyncio + async def test_set_time_offset_too_large(self): + device = self.create_device() + max_offset = SwitchbotMeterProCO2.MAX_TIME_OFFSET + + with pytest.raises(SwitchbotOperationError): + await device.set_time_offset(max_offset + 1) + + with pytest.raises(SwitchbotOperationError): + await device.set_time_offset(-(max_offset + 1)) + + @pytest.mark.asyncio + async def test_set_time_offset_failure(self): + device = self.create_device() + device._send_command.return_value = bytes.fromhex("00") + + with pytest.raises(SwitchbotOperationError): + await device.set_time_offset(100) From 7aaa0ef2297f49fc42c5b3b2846edaa42f2f5f15 Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Thu, 1 Jan 2026 00:51:03 +0100 Subject: [PATCH 03/21] Implement getting the currently displayed datetime info from the SwitchbotMeterProCO2 device. --- switchbot/devices/meter.py | 41 +++++++++++++++++++++++++ tests/test_meter.py | 63 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/switchbot/devices/meter.py b/switchbot/devices/meter.py index 8702c8e4..d12a288b 100644 --- a/switchbot/devices/meter.py +++ b/switchbot/devices/meter.py @@ -17,6 +17,8 @@ class SwitchbotMeterProCO2(SwitchbotDevice): COMMAND_GET_TIME_OFFSET = "570f690506" MAX_TIME_OFFSET = 1 << 24 - 1 + COMMAND_GET_DEVICE_DATETIME = "570f6901" + async def get_time_offset(self) -> int: """ Get the current display time offset from the device. @@ -60,6 +62,45 @@ async def set_time_offset(self, offset_seconds: int): self._validate_result('set_time_offset', result) + async def get_datetime(self) -> dict: + """ + Get the current device time and settings as it is displayed. Contains + a time offset, if any was applied (see COMMAND_TIME_OFFSET). + Doesn't include the current time zone. + + Returns: + dict: Dictionary containing: + - 12h_mode (bool): True if 12h mode, False if 24h mode. + - year (int) + - month (int) + - day (int) + - hour (int) + - minute (int) + - second (int) + """ + # Response Format: 13 bytes, where + # - byte 0: "01" (success) + # * bytes 1-4: temperature, ignored here. + # * byte 5: time display format: + # * "80" - 12h (am/pm) + # * "00" - 24h + # * bytes 6-12: yyyy-MM-dd-hh-mm-ss + # Example: 01-e4-02-94-23-00-07-e9-0c-1e -08-37-01 contains + # "year 2025, 30 December, 08:55:01, displayed in 24h format". + result = await self._send_command(self.COMMAND_GET_DEVICE_DATETIME) + result = self._validate_result( + 'get_datetime', result, min_length=13) + return { + # Whether the time is displayed in 12h(am/pm) or 24h mode. + "12h_mode": result[5] == 0x80, + "year": (result[6] << 8) + result[7], + "month": result[8], + "day": result[9], + "hour": result[10], + "minute": result[11], + "second": result[12], + } + def _validate_result(self, op_name: str, result: bytes | None, min_length: int | None = None) -> bytes: if not self._check_command_result(result, 0, {1}): raise SwitchbotOperationError( diff --git a/tests/test_meter.py b/tests/test_meter.py index 65b4111c..bfe6730d 100644 --- a/tests/test_meter.py +++ b/tests/test_meter.py @@ -89,3 +89,66 @@ async def test_set_time_offset_failure(self): with pytest.raises(SwitchbotOperationError): await device.set_time_offset(100) + + @pytest.mark.asyncio + async def test_get_datetime_success(self): + device = self.create_device() + # Mock response: + # byte 0: 01 (success) + # bytes 1-4: e4 02 94 23 (temp, ignored) + # byte 5: 00 (24h mode) + # bytes 6-7: 07 e9 (year 2025) + # byte 8: 0c (Dec) + # byte 9: 1e (30) + # byte 10: 08 (Hour) + # byte 11: 37 (Minute = 55) + # byte 12: 01 (Second) + # Total: 13 bytes + response_hex = "01e40294230007e90c1e083701" + device._send_command.return_value = bytes.fromhex(response_hex) + + result = await device.get_datetime() + device._send_command.assert_called_with("570f6901") + + assert result["12h_mode"] is False + assert result["year"] == 2025 + assert result["month"] == 12 + assert result["day"] == 30 + assert result["hour"] == 8 + assert result["minute"] == 55 + assert result["second"] == 1 + + @pytest.mark.asyncio + async def test_get_datetime_12h_mode(self): + device = self.create_device() + # byte 5: 80 (12h mode) + # Time: 12:00:00 + response_hex = "010000000080000001010c0000" + device._send_command.return_value = bytes.fromhex(response_hex) + + result = await device.get_datetime() + device._send_command.assert_called_with("570f6901") + + assert result["12h_mode"] is True + assert result["year"] == 0 + assert result["month"] == 1 + assert result["day"] == 1 + assert result["hour"] == 12 + assert result["minute"] == 0 + assert result["second"] == 0 + + @pytest.mark.asyncio + async def test_get_datetime_failure(self): + device = self.create_device() + device._send_command.return_value = bytes.fromhex("00") + + with pytest.raises(SwitchbotOperationError): + await device.get_datetime() + + @pytest.mark.asyncio + async def test_get_datetime_wrong_response(self): + device = self.create_device() + device._send_command.return_value = bytes.fromhex("0100") + + with pytest.raises(SwitchbotOperationError): + await device.get_datetime() From 6898782c900d799f1226958aa6c287ea3e2fc4d4 Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Thu, 1 Jan 2026 00:53:04 +0100 Subject: [PATCH 04/21] Implement setting the datetime on SwitchbotMeterProCO2 device. --- switchbot/devices/meter.py | 40 +++++++++++++++++++++++++++++++++ tests/test_meter.py | 45 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/switchbot/devices/meter.py b/switchbot/devices/meter.py index d12a288b..7f907792 100644 --- a/switchbot/devices/meter.py +++ b/switchbot/devices/meter.py @@ -1,3 +1,5 @@ +from datetime import datetime + from .device import SwitchbotDevice, SwitchbotOperationError @@ -18,6 +20,7 @@ class SwitchbotMeterProCO2(SwitchbotDevice): MAX_TIME_OFFSET = 1 << 24 - 1 COMMAND_GET_DEVICE_DATETIME = "570f6901" + COMMAND_SET_DEVICE_DATETIME = "57000503" async def get_time_offset(self) -> int: """ @@ -101,6 +104,43 @@ async def get_datetime(self) -> dict: "second": result[12], } + async def set_datetime(self, dt: datetime): + """ + Set the device internal time and timezone. Similar to how the + Switchbot app does it upon syncing with the device. + + Args: + dt (datetime): datetime object with timezone information. + """ + + utc_offset = dt.utcoffset() + if utc_offset is None: + # Fallback to the local timezone. + utc_offset = datetime.now().astimezone().utcoffset() + utc_offset_hours, utc_offset_minutes = 0, 0 + if utc_offset is not None: + total_minutes = int(utc_offset.total_seconds() // 60) + # UTC-04:30 tz is represented as -5hrs +30min + utc_offset_hours, utc_offset_minutes = divmod(total_minutes, 60) + + # The device doesn't automatically add offset minutes, it expects them + # to come as a part of the timestamp. + timestamp = int(dt.timestamp()) + utc_offset_minutes * 60 + + # The timezone is encoded as 1 byte, where 00 stands for UTC-12. + # TZ with minute offset gets floor()ed: 4:30 yields 4, -4:30 yields -5. + utc_byte = utc_offset_hours + 12 + + payload = ( + self.COMMAND_SET_DEVICE_DATETIME + + f"{utc_byte:02x}" + + f"{timestamp:016x}" + + f"{utc_offset_minutes:02x}" + ) + + result = await self._send_command(payload) + self._validate_result('set_datetime', result) + def _validate_result(self, op_name: str, result: bytes | None, min_length: int | None = None) -> bytes: if not self._check_command_result(result, 0, {1}): raise SwitchbotOperationError( diff --git a/tests/test_meter.py b/tests/test_meter.py index bfe6730d..f8576c43 100644 --- a/tests/test_meter.py +++ b/tests/test_meter.py @@ -152,3 +152,48 @@ async def test_get_datetime_wrong_response(self): with pytest.raises(SwitchbotOperationError): await device.get_datetime() + + @pytest.mark.asyncio + async def test_set_datetime_iso_utc(self): + device = self.create_device() + device._send_command.return_value = bytes.fromhex("01") + + # "2024-03-01T00:00:00+00:00" -> Timestamp 1709251200. Offset 0. + await device.set_datetime(datetime.fromisoformat("2024-03-01T00:00:00+00:00")) + + expected_header = "57000503" + expected_utc = "0c" + expected_ts = "0000000065e11a80" + expected_min = "00" + + expected_payload = expected_header + expected_utc + expected_ts + expected_min + device._send_command.assert_called_with(expected_payload) + + @pytest.mark.asyncio + async def test_set_datetime_iso_offset(self): + device = self.create_device() + device._send_command.return_value = bytes.fromhex("01") + + # "2024-03-01T01:00:00+01:00" -> Timestamp 1709251200. Offset +1h. + await device.set_datetime(datetime.fromisoformat("2024-03-01T01:00:00+01:00")) + + expected_header = "57000503" + expected_utc = "0d" + expected_ts = "0000000065e11a80" + expected_min = "00" + expected_payload = expected_header + expected_utc + expected_ts + expected_min + device._send_command.assert_called_with(expected_payload) + + @pytest.mark.asyncio + async def test_set_datetime_iso_irregular(self): + device = self.create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_datetime(datetime.fromisoformat("2024-03-01T05:30:00+05:45")) + + expected_header = "57000503" + expected_utc = "11" + expected_ts = "0000000065e12188" + expected_min = "2d" + expected_payload = expected_header + expected_utc + expected_ts + expected_min + device._send_command.assert_called_with(expected_payload) From 6c2c6f78bcc1733993a6948f92b5ecd2bb8dc716 Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Thu, 1 Jan 2026 00:54:32 +0100 Subject: [PATCH 05/21] Implement setting 12h(AM/PM) or 24h time display format for SwitchbotMeterProCO2 device. --- switchbot/devices/meter.py | 16 ++++++++++++++++ tests/test_meter.py | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/switchbot/devices/meter.py b/switchbot/devices/meter.py index 7f907792..4e2f5453 100644 --- a/switchbot/devices/meter.py +++ b/switchbot/devices/meter.py @@ -21,6 +21,7 @@ class SwitchbotMeterProCO2(SwitchbotDevice): COMMAND_GET_DEVICE_DATETIME = "570f6901" COMMAND_SET_DEVICE_DATETIME = "57000503" + COMMAND_SET_DISPLAY_FORMAT = "570f680505" async def get_time_offset(self) -> int: """ @@ -141,6 +142,21 @@ async def set_datetime(self, dt: datetime): result = await self._send_command(payload) self._validate_result('set_datetime', result) + async def set_time_display_format(self, is_12h_mode: bool = False): + """ + Set the time display format on the device: 12h(AM/PM) or 24h. + + Args: + is_12h_mode (bool): True for 12h (AM/PM) mode, False for 24h mode. + """ + # Command code: 57 0f 68 05 05 + # Payload byte 5: 80 for 12h, 00 for 24h + mode_byte = "80" if is_12h_mode else "00" + + payload = self.COMMAND_SET_DISPLAY_FORMAT + mode_byte + result = await self._send_command(payload) + self._validate_result('set_time_display_format', result) + def _validate_result(self, op_name: str, result: bytes | None, min_length: int | None = None) -> bytes: if not self._check_command_result(result, 0, {1}): raise SwitchbotOperationError( diff --git a/tests/test_meter.py b/tests/test_meter.py index f8576c43..1665038a 100644 --- a/tests/test_meter.py +++ b/tests/test_meter.py @@ -197,3 +197,27 @@ async def test_set_datetime_iso_irregular(self): expected_min = "2d" expected_payload = expected_header + expected_utc + expected_ts + expected_min device._send_command.assert_called_with(expected_payload) + + @pytest.mark.asyncio + async def test_set_time_display_format_12h(self): + device = self.create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_time_display_format(is_12h_mode=True) + device._send_command.assert_called_with("570f68050580") + + @pytest.mark.asyncio + async def test_set_time_display_format_24h(self): + device = self.create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_time_display_format(is_12h_mode=False) + device._send_command.assert_called_with("570f68050500") + + @pytest.mark.asyncio + async def test_set_time_display_format_failure(self): + device = self.create_device() + device._send_command.return_value = bytes.fromhex("00") + + with pytest.raises(SwitchbotOperationError): + await device.set_time_display_format(is_12h_mode=True) From 0b4a4c32098a47f0f18997417dbdcc01e702f7aa Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Thu, 1 Jan 2026 00:33:18 +0100 Subject: [PATCH 06/21] Moved constants and tests to the top level. --- switchbot/devices/meter.py | 47 ++-- tests/test_meter.py | 436 +++++++++++++++++++------------------ 2 files changed, 249 insertions(+), 234 deletions(-) diff --git a/switchbot/devices/meter.py b/switchbot/devices/meter.py index 4e2f5453..4c1b4d45 100644 --- a/switchbot/devices/meter.py +++ b/switchbot/devices/meter.py @@ -3,25 +3,26 @@ from .device import SwitchbotDevice, SwitchbotOperationError -class SwitchbotMeterProCO2(SwitchbotDevice): - """API to control Switchbot Meter Pro CO2.""" +""" +Command code to set the displayed time offse, which happens whenever you +manually set the device display time in the Switchbot app. + +The displayed time is calculated as the internal device time (usually comes +from the factory settings or set by the Switchbot app upon syncing) ++ offset. The offset is provided in seconds and can be +positive or negative. +""" +COMMAND_SET_TIME_OFFSET = "570f680506" +COMMAND_GET_TIME_OFFSET = "570f690506" +MAX_TIME_OFFSET = 1 << 24 - 1 - """ - Command code to set the displayed time offse, which happens whenever you - manually set the device display time in the Switchbot app. +COMMAND_GET_DEVICE_DATETIME = "570f6901" +COMMAND_SET_DEVICE_DATETIME = "57000503" +COMMAND_SET_DISPLAY_FORMAT = "570f680505" - The displayed time is calculated as the internal device time (usually comes - from the factory settings or set by the Switchbot app upon syncing) - + offset. The offset is provided in seconds and can be - positive or negative. - """ - COMMAND_SET_TIME_OFFSET = "570f680506" - COMMAND_GET_TIME_OFFSET = "570f690506" - MAX_TIME_OFFSET = 1 << 24 - 1 - COMMAND_GET_DEVICE_DATETIME = "570f6901" - COMMAND_SET_DEVICE_DATETIME = "57000503" - COMMAND_SET_DISPLAY_FORMAT = "570f680505" +class SwitchbotMeterProCO2(SwitchbotDevice): + """API to control Switchbot Meter Pro CO2.""" async def get_time_offset(self) -> int: """ @@ -35,7 +36,7 @@ async def get_time_offset(self) -> int: # - byte 1: "00" (plus offset) or "80" (minus offset) # - bytes 2-4: int24, number of seconds to offset. # Example response: 01-80-00-10-00 -> subtract 4096 seconds. - result = await self._send_command(self.COMMAND_GET_TIME_OFFSET) + result = await self._send_command(COMMAND_GET_TIME_OFFSET) result = self._validate_result( 'get_time_offset', result, min_length=5) @@ -52,15 +53,15 @@ async def set_time_offset(self, offset_seconds: int): offset_seconds (int): 2^24 maximum, can be negative. """ abs_offset = abs(offset_seconds) - if abs_offset > self.MAX_TIME_OFFSET: + if abs_offset > MAX_TIME_OFFSET: raise SwitchbotOperationError( - f"{self.name}: Requested to set_time_offset of {offset_seconds} seconds, allowed +-{self.MAX_TIME_OFFSET} max." + f"{self.name}: Requested to set_time_offset of {offset_seconds} seconds, allowed +-{MAX_TIME_OFFSET} max." ) sign_byte = "80" if offset_seconds < 0 else "00" # Example: 57-0f-68-05-06-80-00-10-00 -> subtract 4096 seconds. - payload = self.COMMAND_SET_TIME_OFFSET + \ + payload = COMMAND_SET_TIME_OFFSET + \ sign_byte + f"{abs_offset:06x}" result = await self._send_command(payload) @@ -91,7 +92,7 @@ async def get_datetime(self) -> dict: # * bytes 6-12: yyyy-MM-dd-hh-mm-ss # Example: 01-e4-02-94-23-00-07-e9-0c-1e -08-37-01 contains # "year 2025, 30 December, 08:55:01, displayed in 24h format". - result = await self._send_command(self.COMMAND_GET_DEVICE_DATETIME) + result = await self._send_command(COMMAND_GET_DEVICE_DATETIME) result = self._validate_result( 'get_datetime', result, min_length=13) return { @@ -133,7 +134,7 @@ async def set_datetime(self, dt: datetime): utc_byte = utc_offset_hours + 12 payload = ( - self.COMMAND_SET_DEVICE_DATETIME + COMMAND_SET_DEVICE_DATETIME + f"{utc_byte:02x}" + f"{timestamp:016x}" + f"{utc_offset_minutes:02x}" @@ -153,7 +154,7 @@ async def set_time_display_format(self, is_12h_mode: bool = False): # Payload byte 5: 80 for 12h, 00 for 24h mode_byte = "80" if is_12h_mode else "00" - payload = self.COMMAND_SET_DISPLAY_FORMAT + mode_byte + payload = COMMAND_SET_DISPLAY_FORMAT + mode_byte result = await self._send_command(payload) self._validate_result('set_time_display_format', result) diff --git a/tests/test_meter.py b/tests/test_meter.py index 1665038a..b83cef20 100644 --- a/tests/test_meter.py +++ b/tests/test_meter.py @@ -5,219 +5,233 @@ from datetime import datetime from switchbot import SwitchbotOperationError -from switchbot.devices.meter import SwitchbotMeterProCO2 - - -class TestSwitchbotMeterProCO2: - def create_device(self): - ble_device = BLEDevice(address="aa:bb:cc:dd:ee:ff", - name="any", details={"rssi": -80}) - device = SwitchbotMeterProCO2(ble_device) - device._send_command = AsyncMock() - return device - - @pytest.mark.asyncio - async def test_get_time_offset_positive(self): - device = self.create_device() - # Mock response: 01 (success) 00 (plus offset) 10 1b c9 (1055689 seconds) - device._send_command.return_value = bytes.fromhex("0100101bc9") - - offset = await device.get_time_offset() - device._send_command.assert_called_with("570f690506") - assert offset == 1055689 - - @pytest.mark.asyncio - async def test_get_time_offset_negative(self): - device = self.create_device() - # Mock response: 01 (success) 80 (minus offset) 10 1b c9 (1055689 seconds) - device._send_command.return_value = bytes.fromhex("0180101bc9") - - offset = await device.get_time_offset() - device._send_command.assert_called_with("570f690506") - assert offset == -1055689 - - @pytest.mark.asyncio - async def test_get_time_offset_failure(self): - device = self.create_device() - # Invalid 1st byte - device._send_command.return_value = bytes.fromhex("0080101bc9") - - with pytest.raises(SwitchbotOperationError): - await device.get_time_offset() - device._send_command.assert_called_with("570f690506") - - @pytest.mark.asyncio - async def test_get_time_offset_wrong_response(self): - device = self.create_device() - # Invalid 1st byte - device._send_command.return_value = bytes.fromhex("01") - - with pytest.raises(SwitchbotOperationError): - await device.get_time_offset() - - @pytest.mark.asyncio - async def test_set_time_offset_positive(self): - device = self.create_device() - device._send_command.return_value = bytes.fromhex("01") - - await device.set_time_offset(1055689) - device._send_command.assert_called_with("570f68050600101bc9") - - @pytest.mark.asyncio - async def test_set_time_offset_negative(self): - device = self.create_device() - device._send_command.return_value = bytes.fromhex("01") - - await device.set_time_offset(-4096) - device._send_command.assert_called_with("570f68050680001000") - - @pytest.mark.asyncio - async def test_set_time_offset_too_large(self): - device = self.create_device() - max_offset = SwitchbotMeterProCO2.MAX_TIME_OFFSET - - with pytest.raises(SwitchbotOperationError): - await device.set_time_offset(max_offset + 1) - - with pytest.raises(SwitchbotOperationError): - await device.set_time_offset(-(max_offset + 1)) - - @pytest.mark.asyncio - async def test_set_time_offset_failure(self): - device = self.create_device() - device._send_command.return_value = bytes.fromhex("00") - - with pytest.raises(SwitchbotOperationError): - await device.set_time_offset(100) - - @pytest.mark.asyncio - async def test_get_datetime_success(self): - device = self.create_device() - # Mock response: - # byte 0: 01 (success) - # bytes 1-4: e4 02 94 23 (temp, ignored) - # byte 5: 00 (24h mode) - # bytes 6-7: 07 e9 (year 2025) - # byte 8: 0c (Dec) - # byte 9: 1e (30) - # byte 10: 08 (Hour) - # byte 11: 37 (Minute = 55) - # byte 12: 01 (Second) - # Total: 13 bytes - response_hex = "01e40294230007e90c1e083701" - device._send_command.return_value = bytes.fromhex(response_hex) - - result = await device.get_datetime() - device._send_command.assert_called_with("570f6901") - - assert result["12h_mode"] is False - assert result["year"] == 2025 - assert result["month"] == 12 - assert result["day"] == 30 - assert result["hour"] == 8 - assert result["minute"] == 55 - assert result["second"] == 1 - - @pytest.mark.asyncio - async def test_get_datetime_12h_mode(self): - device = self.create_device() - # byte 5: 80 (12h mode) - # Time: 12:00:00 - response_hex = "010000000080000001010c0000" - device._send_command.return_value = bytes.fromhex(response_hex) - - result = await device.get_datetime() - device._send_command.assert_called_with("570f6901") - - assert result["12h_mode"] is True - assert result["year"] == 0 - assert result["month"] == 1 - assert result["day"] == 1 - assert result["hour"] == 12 - assert result["minute"] == 0 - assert result["second"] == 0 - - @pytest.mark.asyncio - async def test_get_datetime_failure(self): - device = self.create_device() - device._send_command.return_value = bytes.fromhex("00") - - with pytest.raises(SwitchbotOperationError): - await device.get_datetime() - - @pytest.mark.asyncio - async def test_get_datetime_wrong_response(self): - device = self.create_device() - device._send_command.return_value = bytes.fromhex("0100") - - with pytest.raises(SwitchbotOperationError): - await device.get_datetime() - - @pytest.mark.asyncio - async def test_set_datetime_iso_utc(self): - device = self.create_device() - device._send_command.return_value = bytes.fromhex("01") - - # "2024-03-01T00:00:00+00:00" -> Timestamp 1709251200. Offset 0. - await device.set_datetime(datetime.fromisoformat("2024-03-01T00:00:00+00:00")) - - expected_header = "57000503" - expected_utc = "0c" - expected_ts = "0000000065e11a80" - expected_min = "00" - - expected_payload = expected_header + expected_utc + expected_ts + expected_min - device._send_command.assert_called_with(expected_payload) - - @pytest.mark.asyncio - async def test_set_datetime_iso_offset(self): - device = self.create_device() - device._send_command.return_value = bytes.fromhex("01") - - # "2024-03-01T01:00:00+01:00" -> Timestamp 1709251200. Offset +1h. - await device.set_datetime(datetime.fromisoformat("2024-03-01T01:00:00+01:00")) - - expected_header = "57000503" - expected_utc = "0d" - expected_ts = "0000000065e11a80" - expected_min = "00" - expected_payload = expected_header + expected_utc + expected_ts + expected_min - device._send_command.assert_called_with(expected_payload) - - @pytest.mark.asyncio - async def test_set_datetime_iso_irregular(self): - device = self.create_device() - device._send_command.return_value = bytes.fromhex("01") - - await device.set_datetime(datetime.fromisoformat("2024-03-01T05:30:00+05:45")) - - expected_header = "57000503" - expected_utc = "11" - expected_ts = "0000000065e12188" - expected_min = "2d" - expected_payload = expected_header + expected_utc + expected_ts + expected_min - device._send_command.assert_called_with(expected_payload) - - @pytest.mark.asyncio - async def test_set_time_display_format_12h(self): - device = self.create_device() - device._send_command.return_value = bytes.fromhex("01") +from switchbot.devices.meter import SwitchbotMeterProCO2, MAX_TIME_OFFSET - await device.set_time_display_format(is_12h_mode=True) - device._send_command.assert_called_with("570f68050580") - @pytest.mark.asyncio - async def test_set_time_display_format_24h(self): - device = self.create_device() - device._send_command.return_value = bytes.fromhex("01") +def create_device(): + ble_device = BLEDevice(address="aa:bb:cc:dd:ee:ff", + name="any", details={"rssi": -80}) + device = SwitchbotMeterProCO2(ble_device) + device._send_command = AsyncMock() + return device + + +@pytest.mark.asyncio +async def test_get_time_offset_positive(): + device = create_device() + # Mock response: 01 (success) 00 (plus offset) 10 1b c9 (1055689 seconds) + device._send_command.return_value = bytes.fromhex("0100101bc9") + + offset = await device.get_time_offset() + device._send_command.assert_called_with("570f690506") + assert offset == 1055689 + + +@pytest.mark.asyncio +async def test_get_time_offset_negative(): + device = create_device() + # Mock response: 01 (success) 80 (minus offset) 10 1b c9 (1055689 seconds) + device._send_command.return_value = bytes.fromhex("0180101bc9") + + offset = await device.get_time_offset() + device._send_command.assert_called_with("570f690506") + assert offset == -1055689 + + +@pytest.mark.asyncio +async def test_get_time_offset_failure(): + device = create_device() + # Invalid 1st byte + device._send_command.return_value = bytes.fromhex("0080101bc9") + + with pytest.raises(SwitchbotOperationError): + await device.get_time_offset() + device._send_command.assert_called_with("570f690506") + + +@pytest.mark.asyncio +async def test_get_time_offset_wrong_response(): + device = create_device() + # Invalid 1st byte + device._send_command.return_value = bytes.fromhex("01") + + with pytest.raises(SwitchbotOperationError): + await device.get_time_offset() + + +@pytest.mark.asyncio +async def test_set_time_offset_positive(): + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_time_offset(1055689) + device._send_command.assert_called_with("570f68050600101bc9") + + +@pytest.mark.asyncio +async def test_set_time_offset_negative(): + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_time_offset(-4096) + device._send_command.assert_called_with("570f68050680001000") + + +@pytest.mark.asyncio +async def test_set_time_offset_too_large(): + device = create_device() + with pytest.raises(SwitchbotOperationError): + await device.set_time_offset(MAX_TIME_OFFSET + 1) + + with pytest.raises(SwitchbotOperationError): + await device.set_time_offset(-(MAX_TIME_OFFSET + 1)) + + +@pytest.mark.asyncio +async def test_set_time_offset_failure(): + device = create_device() + device._send_command.return_value = bytes.fromhex("00") + + with pytest.raises(SwitchbotOperationError): + await device.set_time_offset(100) + + +@pytest.mark.asyncio +async def test_get_datetime_success(): + device = create_device() + # Mock response: + # byte 0: 01 (success) + # bytes 1-4: e4 02 94 23 (temp, ignored) + # byte 5: 00 (24h mode) + # bytes 6-7: 07 e9 (year 2025) + # byte 8: 0c (Dec) + # byte 9: 1e (30) + # byte 10: 08 (Hour) + # byte 11: 37 (Minute = 55) + # byte 12: 01 (Second) + response_hex = "01e40294230007e90c1e083701" + device._send_command.return_value = bytes.fromhex(response_hex) + + result = await device.get_datetime() + device._send_command.assert_called_with("570f6901") + + assert result["12h_mode"] is False + assert result["year"] == 2025 + assert result["month"] == 12 + assert result["day"] == 30 + assert result["hour"] == 8 + assert result["minute"] == 55 + assert result["second"] == 1 + - await device.set_time_display_format(is_12h_mode=False) - device._send_command.assert_called_with("570f68050500") +@pytest.mark.asyncio +async def test_get_datetime_12h_mode(): + device = create_device() + # byte 5: 80 (12h mode) + # Time: 12:00:00 + response_hex = "010000000080000001010c0000" + device._send_command.return_value = bytes.fromhex(response_hex) - @pytest.mark.asyncio - async def test_set_time_display_format_failure(self): - device = self.create_device() - device._send_command.return_value = bytes.fromhex("00") + result = await device.get_datetime() + device._send_command.assert_called_with("570f6901") - with pytest.raises(SwitchbotOperationError): - await device.set_time_display_format(is_12h_mode=True) + assert result["12h_mode"] is True + assert result["year"] == 0 + assert result["month"] == 1 + assert result["day"] == 1 + assert result["hour"] == 12 + assert result["minute"] == 0 + assert result["second"] == 0 + + +@pytest.mark.asyncio +async def test_get_datetime_failure(): + device = create_device() + device._send_command.return_value = bytes.fromhex("00") + + with pytest.raises(SwitchbotOperationError): + await device.get_datetime() + + +@pytest.mark.asyncio +async def test_get_datetime_wrong_response(): + device = create_device() + device._send_command.return_value = bytes.fromhex("0100") + + with pytest.raises(SwitchbotOperationError): + await device.get_datetime() + + +@pytest.mark.asyncio +async def test_set_datetime_iso_utc(): + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + # "2024-03-01T00:00:00+00:00" -> Timestamp 1709251200. Offset 0. + await device.set_datetime(datetime.fromisoformat("2024-03-01T00:00:00+00:00")) + + expected_header = "57000503" + expected_utc = "0c" + expected_ts = "0000000065e11a80" + expected_min = "00" + + expected_payload = expected_header + expected_utc + expected_ts + expected_min + device._send_command.assert_called_with(expected_payload) + + +@pytest.mark.asyncio +async def test_set_datetime_iso_offset(): + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + # "2024-03-01T01:00:00+01:00" -> Timestamp 1709251200. Offset +1h. + await device.set_datetime(datetime.fromisoformat("2024-03-01T01:00:00+01:00")) + + expected_header = "57000503" + expected_utc = "0d" + expected_ts = "0000000065e11a80" + expected_min = "00" + expected_payload = expected_header + expected_utc + expected_ts + expected_min + device._send_command.assert_called_with(expected_payload) + + +@pytest.mark.asyncio +async def test_set_datetime_iso_irregular(): + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_datetime(datetime.fromisoformat("2024-03-01T05:30:00+05:45")) + + expected_header = "57000503" + expected_utc = "11" + expected_ts = "0000000065e12188" + expected_min = "2d" + expected_payload = expected_header + expected_utc + expected_ts + expected_min + device._send_command.assert_called_with(expected_payload) + + +@pytest.mark.asyncio +async def test_set_time_display_format_12h(): + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_time_display_format(is_12h_mode=True) + device._send_command.assert_called_with("570f68050580") + + +@pytest.mark.asyncio +async def test_set_time_display_format_24h(): + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_time_display_format(is_12h_mode=False) + device._send_command.assert_called_with("570f68050500") + + +@pytest.mark.asyncio +async def test_set_time_display_format_failure(): + device = create_device() + device._send_command.return_value = bytes.fromhex("00") + + with pytest.raises(SwitchbotOperationError): + await device.set_time_display_format(is_12h_mode=True) From 4dcd2539d6734c6fd5148e6e74de31e3c28046b7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 23:57:02 +0000 Subject: [PATCH 07/21] chore(pre-commit.ci): auto fixes --- switchbot/devices/meter.py | 30 ++++++++++++++++-------------- tests/test_meter.py | 9 +++++---- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/switchbot/devices/meter.py b/switchbot/devices/meter.py index 4c1b4d45..14dc162a 100644 --- a/switchbot/devices/meter.py +++ b/switchbot/devices/meter.py @@ -2,7 +2,6 @@ from .device import SwitchbotDevice, SwitchbotOperationError - """ Command code to set the displayed time offse, which happens whenever you manually set the device display time in the Switchbot app. @@ -30,6 +29,7 @@ async def get_time_offset(self) -> int: Returns: int: The time offset in seconds. Max 24 bits. + """ # Response Format: 5 bytes, where # - byte 0: "01" (success) @@ -37,8 +37,7 @@ async def get_time_offset(self) -> int: # - bytes 2-4: int24, number of seconds to offset. # Example response: 01-80-00-10-00 -> subtract 4096 seconds. result = await self._send_command(COMMAND_GET_TIME_OFFSET) - result = self._validate_result( - 'get_time_offset', result, min_length=5) + result = self._validate_result("get_time_offset", result, min_length=5) is_negative = result[1] == 0x80 offset = (result[2] << 16) + (result[3] << 8) + result[4] @@ -51,6 +50,7 @@ async def set_time_offset(self, offset_seconds: int): Args: offset_seconds (int): 2^24 maximum, can be negative. + """ abs_offset = abs(offset_seconds) if abs_offset > MAX_TIME_OFFSET: @@ -61,11 +61,10 @@ async def set_time_offset(self, offset_seconds: int): sign_byte = "80" if offset_seconds < 0 else "00" # Example: 57-0f-68-05-06-80-00-10-00 -> subtract 4096 seconds. - payload = COMMAND_SET_TIME_OFFSET + \ - sign_byte + f"{abs_offset:06x}" + payload = COMMAND_SET_TIME_OFFSET + sign_byte + f"{abs_offset:06x}" result = await self._send_command(payload) - self._validate_result('set_time_offset', result) + self._validate_result("set_time_offset", result) async def get_datetime(self) -> dict: """ @@ -82,6 +81,7 @@ async def get_datetime(self) -> dict: - hour (int) - minute (int) - second (int) + """ # Response Format: 13 bytes, where # - byte 0: "01" (success) @@ -93,8 +93,7 @@ async def get_datetime(self) -> dict: # Example: 01-e4-02-94-23-00-07-e9-0c-1e -08-37-01 contains # "year 2025, 30 December, 08:55:01, displayed in 24h format". result = await self._send_command(COMMAND_GET_DEVICE_DATETIME) - result = self._validate_result( - 'get_datetime', result, min_length=13) + result = self._validate_result("get_datetime", result, min_length=13) return { # Whether the time is displayed in 12h(am/pm) or 24h mode. "12h_mode": result[5] == 0x80, @@ -113,8 +112,8 @@ async def set_datetime(self, dt: datetime): Args: dt (datetime): datetime object with timezone information. - """ + """ utc_offset = dt.utcoffset() if utc_offset is None: # Fallback to the local timezone. @@ -141,7 +140,7 @@ async def set_datetime(self, dt: datetime): ) result = await self._send_command(payload) - self._validate_result('set_datetime', result) + self._validate_result("set_datetime", result) async def set_time_display_format(self, is_12h_mode: bool = False): """ @@ -149,6 +148,7 @@ async def set_time_display_format(self, is_12h_mode: bool = False): Args: is_12h_mode (bool): True for 12h (AM/PM) mode, False for 24h mode. + """ # Command code: 57 0f 68 05 05 # Payload byte 5: 80 for 12h, 00 for 24h @@ -156,16 +156,18 @@ async def set_time_display_format(self, is_12h_mode: bool = False): payload = COMMAND_SET_DISPLAY_FORMAT + mode_byte result = await self._send_command(payload) - self._validate_result('set_time_display_format', result) + self._validate_result("set_time_display_format", result) - def _validate_result(self, op_name: str, result: bytes | None, min_length: int | None = None) -> bytes: + def _validate_result( + self, op_name: str, result: bytes | None, min_length: int | None = None + ) -> bytes: if not self._check_command_result(result, 0, {1}): raise SwitchbotOperationError( - f"{self.name}: Unexpected response code for {op_name} (result={result.hex() if result else "None"} rssi={self.rssi})" + f"{self.name}: Unexpected response code for {op_name} (result={result.hex() if result else 'None'} rssi={self.rssi})" ) assert result is not None if min_length is not None and len(result) < min_length: raise SwitchbotOperationError( - f"{self.name}: Unexpected response len for {op_name}, wanted at least {min_length} (result={result.hex() if result else "None"} rssi={self.rssi})" + f"{self.name}: Unexpected response len for {op_name}, wanted at least {min_length} (result={result.hex() if result else 'None'} rssi={self.rssi})" ) return result diff --git a/tests/test_meter.py b/tests/test_meter.py index b83cef20..a06737d4 100644 --- a/tests/test_meter.py +++ b/tests/test_meter.py @@ -1,16 +1,17 @@ +from datetime import datetime from unittest.mock import AsyncMock import pytest from bleak.backends.device import BLEDevice -from datetime import datetime from switchbot import SwitchbotOperationError -from switchbot.devices.meter import SwitchbotMeterProCO2, MAX_TIME_OFFSET +from switchbot.devices.meter import MAX_TIME_OFFSET, SwitchbotMeterProCO2 def create_device(): - ble_device = BLEDevice(address="aa:bb:cc:dd:ee:ff", - name="any", details={"rssi": -80}) + ble_device = BLEDevice( + address="aa:bb:cc:dd:ee:ff", name="any", details={"rssi": -80} + ) device = SwitchbotMeterProCO2(ble_device) device._send_command = AsyncMock() return device From d6fc35c3cb4f7c285b350511306c56eeaaab767f Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Thu, 1 Jan 2026 01:05:19 +0100 Subject: [PATCH 08/21] Move the doc comment from COMMAND_SET_TIME_OFFSET to the method. First, the comment is device-specific. Other devices might implement it differently. Second, top-level comment breaks CI checks. --- switchbot/devices/meter.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/switchbot/devices/meter.py b/switchbot/devices/meter.py index 14dc162a..229a9810 100644 --- a/switchbot/devices/meter.py +++ b/switchbot/devices/meter.py @@ -1,16 +1,6 @@ from datetime import datetime - from .device import SwitchbotDevice, SwitchbotOperationError -""" -Command code to set the displayed time offse, which happens whenever you -manually set the device display time in the Switchbot app. - -The displayed time is calculated as the internal device time (usually comes -from the factory settings or set by the Switchbot app upon syncing) -+ offset. The offset is provided in seconds and can be -positive or negative. -""" COMMAND_SET_TIME_OFFSET = "570f680506" COMMAND_GET_TIME_OFFSET = "570f690506" MAX_TIME_OFFSET = 1 << 24 - 1 @@ -45,8 +35,11 @@ async def get_time_offset(self) -> int: async def set_time_offset(self, offset_seconds: int): """ - Set the display time offset on the device. - This is what happens when you adjust display time in the Switchbot app. + Set the display time offset on the device. This is what happens when + you adjust display time in the Switchbot app. The displayed time is + calculated as the internal device time (usually comes from the factory + settings or set by the Switchbot app upon syncing) + offset. The offset + is provided in seconds and can be positive or negative. Args: offset_seconds (int): 2^24 maximum, can be negative. From 8b6b304fafa6263bd79f9f5cd71d25961e22bdb9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 00:07:51 +0000 Subject: [PATCH 09/21] chore(pre-commit.ci): auto fixes --- switchbot/devices/meter.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/switchbot/devices/meter.py b/switchbot/devices/meter.py index 229a9810..c84f2dc6 100644 --- a/switchbot/devices/meter.py +++ b/switchbot/devices/meter.py @@ -1,4 +1,5 @@ from datetime import datetime + from .device import SwitchbotDevice, SwitchbotOperationError COMMAND_SET_TIME_OFFSET = "570f680506" @@ -35,9 +36,9 @@ async def get_time_offset(self) -> int: async def set_time_offset(self, offset_seconds: int): """ - Set the display time offset on the device. This is what happens when - you adjust display time in the Switchbot app. The displayed time is - calculated as the internal device time (usually comes from the factory + Set the display time offset on the device. This is what happens when + you adjust display time in the Switchbot app. The displayed time is + calculated as the internal device time (usually comes from the factory settings or set by the Switchbot app upon syncing) + offset. The offset is provided in seconds and can be positive or negative. From 653e2124891bfa8209d0bc1a22999b9ccf3fcd45 Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Thu, 1 Jan 2026 10:22:08 +0100 Subject: [PATCH 10/21] Comment and code formatting fixes after a round of self-review, also replaced byte value comparison with a bit op for consistency with the rest of the codebase. --- switchbot/devices/meter.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/switchbot/devices/meter.py b/switchbot/devices/meter.py index c84f2dc6..80fde211 100644 --- a/switchbot/devices/meter.py +++ b/switchbot/devices/meter.py @@ -55,9 +55,12 @@ async def set_time_offset(self, offset_seconds: int): sign_byte = "80" if offset_seconds < 0 else "00" # Example: 57-0f-68-05-06-80-00-10-00 -> subtract 4096 seconds. - payload = COMMAND_SET_TIME_OFFSET + sign_byte + f"{abs_offset:06x}" + payload = ( + COMMAND_SET_TIME_OFFSET + + sign_byte + + f"{abs_offset:06x}" + ) result = await self._send_command(payload) - self._validate_result("set_time_offset", result) async def get_datetime(self) -> dict: @@ -79,18 +82,18 @@ async def get_datetime(self) -> dict: """ # Response Format: 13 bytes, where # - byte 0: "01" (success) - # * bytes 1-4: temperature, ignored here. - # * byte 5: time display format: - # * "80" - 12h (am/pm) - # * "00" - 24h - # * bytes 6-12: yyyy-MM-dd-hh-mm-ss - # Example: 01-e4-02-94-23-00-07-e9-0c-1e -08-37-01 contains + # - bytes 1-4: temperature, ignored here. + # - byte 5: time display format: + # - "80" - 12h (am/pm) + # - "00" - 24h + # - bytes 6-12: yyyy-MM-dd-hh-mm-ss + # Example: 01-e4-02-94-23-00-07-e9-0c-1e-08-37-01 contains # "year 2025, 30 December, 08:55:01, displayed in 24h format". result = await self._send_command(COMMAND_GET_DEVICE_DATETIME) result = self._validate_result("get_datetime", result, min_length=13) return { # Whether the time is displayed in 12h(am/pm) or 24h mode. - "12h_mode": result[5] == 0x80, + "12h_mode": bool(result[5] & 0b10000000), "year": (result[6] << 8) + result[7], "month": result[8], "day": result[9], @@ -144,8 +147,6 @@ async def set_time_display_format(self, is_12h_mode: bool = False): is_12h_mode (bool): True for 12h (AM/PM) mode, False for 24h mode. """ - # Command code: 57 0f 68 05 05 - # Payload byte 5: 80 for 12h, 00 for 24h mode_byte = "80" if is_12h_mode else "00" payload = COMMAND_SET_DISPLAY_FORMAT + mode_byte From 086428c7393f70751203d4a0c525306f47d87cbd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 09:22:38 +0000 Subject: [PATCH 11/21] chore(pre-commit.ci): auto fixes --- switchbot/devices/meter.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/switchbot/devices/meter.py b/switchbot/devices/meter.py index 80fde211..ad058cd9 100644 --- a/switchbot/devices/meter.py +++ b/switchbot/devices/meter.py @@ -55,11 +55,7 @@ async def set_time_offset(self, offset_seconds: int): sign_byte = "80" if offset_seconds < 0 else "00" # Example: 57-0f-68-05-06-80-00-10-00 -> subtract 4096 seconds. - payload = ( - COMMAND_SET_TIME_OFFSET - + sign_byte - + f"{abs_offset:06x}" - ) + payload = COMMAND_SET_TIME_OFFSET + sign_byte + f"{abs_offset:06x}" result = await self._send_command(payload) self._validate_result("set_time_offset", result) From d2c498adf02e140b4563bbf910bfbd3217dfa9ad Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Sun, 4 Jan 2026 19:29:55 +0100 Subject: [PATCH 12/21] Use a helper for parsing 24 bit ints, instead of manual bit ops. Also replaces a comparison with "80" byte with a bit op for consistency with the rest of the codebase. --- switchbot/devices/meter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/switchbot/devices/meter.py b/switchbot/devices/meter.py index ad058cd9..a07244ee 100644 --- a/switchbot/devices/meter.py +++ b/switchbot/devices/meter.py @@ -1,6 +1,6 @@ from datetime import datetime - from .device import SwitchbotDevice, SwitchbotOperationError +from ..helpers import parse_uint24_be COMMAND_SET_TIME_OFFSET = "570f680506" COMMAND_GET_TIME_OFFSET = "570f690506" @@ -30,8 +30,8 @@ async def get_time_offset(self) -> int: result = await self._send_command(COMMAND_GET_TIME_OFFSET) result = self._validate_result("get_time_offset", result, min_length=5) - is_negative = result[1] == 0x80 - offset = (result[2] << 16) + (result[3] << 8) + result[4] + is_negative = bool(result[1] & 0b10000000) + offset = parse_uint24_be(result, 2) return -offset if is_negative else offset async def set_time_offset(self, offset_seconds: int): From a4aa2c7ed64e4836b8d2c8f93ca7eb3a2b6f29b0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:32:06 +0000 Subject: [PATCH 13/21] chore(pre-commit.ci): auto fixes --- switchbot/devices/meter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/switchbot/devices/meter.py b/switchbot/devices/meter.py index a07244ee..bbc22105 100644 --- a/switchbot/devices/meter.py +++ b/switchbot/devices/meter.py @@ -1,6 +1,7 @@ from datetime import datetime -from .device import SwitchbotDevice, SwitchbotOperationError + from ..helpers import parse_uint24_be +from .device import SwitchbotDevice, SwitchbotOperationError COMMAND_SET_TIME_OFFSET = "570f680506" COMMAND_GET_TIME_OFFSET = "570f690506" From cebb61951c2f223ec7d9592e84752a91497daf2d Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Wed, 7 Jan 2026 09:41:17 +0100 Subject: [PATCH 14/21] Move SwitchbotMeterProCO2 to a separate file. To avoid clashing with meter devices, device instructions are different between regular meter vs pro devices. --- switchbot/__init__.py | 2 +- switchbot/devices/meter.py | 166 +-------------------- switchbot/devices/meter_pro.py | 165 ++++++++++++++++++++ tests/{test_meter.py => test_meter_pro.py} | 2 +- 4 files changed, 168 insertions(+), 167 deletions(-) create mode 100644 switchbot/devices/meter_pro.py rename tests/{test_meter.py => test_meter_pro.py} (98%) diff --git a/switchbot/__init__.py b/switchbot/__init__.py index 8db2651d..644d04dd 100644 --- a/switchbot/__init__.py +++ b/switchbot/__init__.py @@ -51,7 +51,7 @@ SwitchbotStripLight3, ) from .devices.lock import SwitchbotLock -from .devices.meter import SwitchbotMeterProCO2 +from .devices.meter_pro import SwitchbotMeterProCO2 from .devices.plug import SwitchbotPlugMini from .devices.relay_switch import ( SwitchbotGarageDoorOpener, diff --git a/switchbot/devices/meter.py b/switchbot/devices/meter.py index bbc22105..9d48db4f 100644 --- a/switchbot/devices/meter.py +++ b/switchbot/devices/meter.py @@ -1,165 +1 @@ -from datetime import datetime - -from ..helpers import parse_uint24_be -from .device import SwitchbotDevice, SwitchbotOperationError - -COMMAND_SET_TIME_OFFSET = "570f680506" -COMMAND_GET_TIME_OFFSET = "570f690506" -MAX_TIME_OFFSET = 1 << 24 - 1 - -COMMAND_GET_DEVICE_DATETIME = "570f6901" -COMMAND_SET_DEVICE_DATETIME = "57000503" -COMMAND_SET_DISPLAY_FORMAT = "570f680505" - - -class SwitchbotMeterProCO2(SwitchbotDevice): - """API to control Switchbot Meter Pro CO2.""" - - async def get_time_offset(self) -> int: - """ - Get the current display time offset from the device. - - Returns: - int: The time offset in seconds. Max 24 bits. - - """ - # Response Format: 5 bytes, where - # - byte 0: "01" (success) - # - byte 1: "00" (plus offset) or "80" (minus offset) - # - bytes 2-4: int24, number of seconds to offset. - # Example response: 01-80-00-10-00 -> subtract 4096 seconds. - result = await self._send_command(COMMAND_GET_TIME_OFFSET) - result = self._validate_result("get_time_offset", result, min_length=5) - - is_negative = bool(result[1] & 0b10000000) - offset = parse_uint24_be(result, 2) - return -offset if is_negative else offset - - async def set_time_offset(self, offset_seconds: int): - """ - Set the display time offset on the device. This is what happens when - you adjust display time in the Switchbot app. The displayed time is - calculated as the internal device time (usually comes from the factory - settings or set by the Switchbot app upon syncing) + offset. The offset - is provided in seconds and can be positive or negative. - - Args: - offset_seconds (int): 2^24 maximum, can be negative. - - """ - abs_offset = abs(offset_seconds) - if abs_offset > MAX_TIME_OFFSET: - raise SwitchbotOperationError( - f"{self.name}: Requested to set_time_offset of {offset_seconds} seconds, allowed +-{MAX_TIME_OFFSET} max." - ) - - sign_byte = "80" if offset_seconds < 0 else "00" - - # Example: 57-0f-68-05-06-80-00-10-00 -> subtract 4096 seconds. - payload = COMMAND_SET_TIME_OFFSET + sign_byte + f"{abs_offset:06x}" - result = await self._send_command(payload) - self._validate_result("set_time_offset", result) - - async def get_datetime(self) -> dict: - """ - Get the current device time and settings as it is displayed. Contains - a time offset, if any was applied (see COMMAND_TIME_OFFSET). - Doesn't include the current time zone. - - Returns: - dict: Dictionary containing: - - 12h_mode (bool): True if 12h mode, False if 24h mode. - - year (int) - - month (int) - - day (int) - - hour (int) - - minute (int) - - second (int) - - """ - # Response Format: 13 bytes, where - # - byte 0: "01" (success) - # - bytes 1-4: temperature, ignored here. - # - byte 5: time display format: - # - "80" - 12h (am/pm) - # - "00" - 24h - # - bytes 6-12: yyyy-MM-dd-hh-mm-ss - # Example: 01-e4-02-94-23-00-07-e9-0c-1e-08-37-01 contains - # "year 2025, 30 December, 08:55:01, displayed in 24h format". - result = await self._send_command(COMMAND_GET_DEVICE_DATETIME) - result = self._validate_result("get_datetime", result, min_length=13) - return { - # Whether the time is displayed in 12h(am/pm) or 24h mode. - "12h_mode": bool(result[5] & 0b10000000), - "year": (result[6] << 8) + result[7], - "month": result[8], - "day": result[9], - "hour": result[10], - "minute": result[11], - "second": result[12], - } - - async def set_datetime(self, dt: datetime): - """ - Set the device internal time and timezone. Similar to how the - Switchbot app does it upon syncing with the device. - - Args: - dt (datetime): datetime object with timezone information. - - """ - utc_offset = dt.utcoffset() - if utc_offset is None: - # Fallback to the local timezone. - utc_offset = datetime.now().astimezone().utcoffset() - utc_offset_hours, utc_offset_minutes = 0, 0 - if utc_offset is not None: - total_minutes = int(utc_offset.total_seconds() // 60) - # UTC-04:30 tz is represented as -5hrs +30min - utc_offset_hours, utc_offset_minutes = divmod(total_minutes, 60) - - # The device doesn't automatically add offset minutes, it expects them - # to come as a part of the timestamp. - timestamp = int(dt.timestamp()) + utc_offset_minutes * 60 - - # The timezone is encoded as 1 byte, where 00 stands for UTC-12. - # TZ with minute offset gets floor()ed: 4:30 yields 4, -4:30 yields -5. - utc_byte = utc_offset_hours + 12 - - payload = ( - COMMAND_SET_DEVICE_DATETIME - + f"{utc_byte:02x}" - + f"{timestamp:016x}" - + f"{utc_offset_minutes:02x}" - ) - - result = await self._send_command(payload) - self._validate_result("set_datetime", result) - - async def set_time_display_format(self, is_12h_mode: bool = False): - """ - Set the time display format on the device: 12h(AM/PM) or 24h. - - Args: - is_12h_mode (bool): True for 12h (AM/PM) mode, False for 24h mode. - - """ - mode_byte = "80" if is_12h_mode else "00" - - payload = COMMAND_SET_DISPLAY_FORMAT + mode_byte - result = await self._send_command(payload) - self._validate_result("set_time_display_format", result) - - def _validate_result( - self, op_name: str, result: bytes | None, min_length: int | None = None - ) -> bytes: - if not self._check_command_result(result, 0, {1}): - raise SwitchbotOperationError( - f"{self.name}: Unexpected response code for {op_name} (result={result.hex() if result else 'None'} rssi={self.rssi})" - ) - assert result is not None - if min_length is not None and len(result) < min_length: - raise SwitchbotOperationError( - f"{self.name}: Unexpected response len for {op_name}, wanted at least {min_length} (result={result.hex() if result else 'None'} rssi={self.rssi})" - ) - return result +from __future__ import annotations diff --git a/switchbot/devices/meter_pro.py b/switchbot/devices/meter_pro.py new file mode 100644 index 00000000..bbc22105 --- /dev/null +++ b/switchbot/devices/meter_pro.py @@ -0,0 +1,165 @@ +from datetime import datetime + +from ..helpers import parse_uint24_be +from .device import SwitchbotDevice, SwitchbotOperationError + +COMMAND_SET_TIME_OFFSET = "570f680506" +COMMAND_GET_TIME_OFFSET = "570f690506" +MAX_TIME_OFFSET = 1 << 24 - 1 + +COMMAND_GET_DEVICE_DATETIME = "570f6901" +COMMAND_SET_DEVICE_DATETIME = "57000503" +COMMAND_SET_DISPLAY_FORMAT = "570f680505" + + +class SwitchbotMeterProCO2(SwitchbotDevice): + """API to control Switchbot Meter Pro CO2.""" + + async def get_time_offset(self) -> int: + """ + Get the current display time offset from the device. + + Returns: + int: The time offset in seconds. Max 24 bits. + + """ + # Response Format: 5 bytes, where + # - byte 0: "01" (success) + # - byte 1: "00" (plus offset) or "80" (minus offset) + # - bytes 2-4: int24, number of seconds to offset. + # Example response: 01-80-00-10-00 -> subtract 4096 seconds. + result = await self._send_command(COMMAND_GET_TIME_OFFSET) + result = self._validate_result("get_time_offset", result, min_length=5) + + is_negative = bool(result[1] & 0b10000000) + offset = parse_uint24_be(result, 2) + return -offset if is_negative else offset + + async def set_time_offset(self, offset_seconds: int): + """ + Set the display time offset on the device. This is what happens when + you adjust display time in the Switchbot app. The displayed time is + calculated as the internal device time (usually comes from the factory + settings or set by the Switchbot app upon syncing) + offset. The offset + is provided in seconds and can be positive or negative. + + Args: + offset_seconds (int): 2^24 maximum, can be negative. + + """ + abs_offset = abs(offset_seconds) + if abs_offset > MAX_TIME_OFFSET: + raise SwitchbotOperationError( + f"{self.name}: Requested to set_time_offset of {offset_seconds} seconds, allowed +-{MAX_TIME_OFFSET} max." + ) + + sign_byte = "80" if offset_seconds < 0 else "00" + + # Example: 57-0f-68-05-06-80-00-10-00 -> subtract 4096 seconds. + payload = COMMAND_SET_TIME_OFFSET + sign_byte + f"{abs_offset:06x}" + result = await self._send_command(payload) + self._validate_result("set_time_offset", result) + + async def get_datetime(self) -> dict: + """ + Get the current device time and settings as it is displayed. Contains + a time offset, if any was applied (see COMMAND_TIME_OFFSET). + Doesn't include the current time zone. + + Returns: + dict: Dictionary containing: + - 12h_mode (bool): True if 12h mode, False if 24h mode. + - year (int) + - month (int) + - day (int) + - hour (int) + - minute (int) + - second (int) + + """ + # Response Format: 13 bytes, where + # - byte 0: "01" (success) + # - bytes 1-4: temperature, ignored here. + # - byte 5: time display format: + # - "80" - 12h (am/pm) + # - "00" - 24h + # - bytes 6-12: yyyy-MM-dd-hh-mm-ss + # Example: 01-e4-02-94-23-00-07-e9-0c-1e-08-37-01 contains + # "year 2025, 30 December, 08:55:01, displayed in 24h format". + result = await self._send_command(COMMAND_GET_DEVICE_DATETIME) + result = self._validate_result("get_datetime", result, min_length=13) + return { + # Whether the time is displayed in 12h(am/pm) or 24h mode. + "12h_mode": bool(result[5] & 0b10000000), + "year": (result[6] << 8) + result[7], + "month": result[8], + "day": result[9], + "hour": result[10], + "minute": result[11], + "second": result[12], + } + + async def set_datetime(self, dt: datetime): + """ + Set the device internal time and timezone. Similar to how the + Switchbot app does it upon syncing with the device. + + Args: + dt (datetime): datetime object with timezone information. + + """ + utc_offset = dt.utcoffset() + if utc_offset is None: + # Fallback to the local timezone. + utc_offset = datetime.now().astimezone().utcoffset() + utc_offset_hours, utc_offset_minutes = 0, 0 + if utc_offset is not None: + total_minutes = int(utc_offset.total_seconds() // 60) + # UTC-04:30 tz is represented as -5hrs +30min + utc_offset_hours, utc_offset_minutes = divmod(total_minutes, 60) + + # The device doesn't automatically add offset minutes, it expects them + # to come as a part of the timestamp. + timestamp = int(dt.timestamp()) + utc_offset_minutes * 60 + + # The timezone is encoded as 1 byte, where 00 stands for UTC-12. + # TZ with minute offset gets floor()ed: 4:30 yields 4, -4:30 yields -5. + utc_byte = utc_offset_hours + 12 + + payload = ( + COMMAND_SET_DEVICE_DATETIME + + f"{utc_byte:02x}" + + f"{timestamp:016x}" + + f"{utc_offset_minutes:02x}" + ) + + result = await self._send_command(payload) + self._validate_result("set_datetime", result) + + async def set_time_display_format(self, is_12h_mode: bool = False): + """ + Set the time display format on the device: 12h(AM/PM) or 24h. + + Args: + is_12h_mode (bool): True for 12h (AM/PM) mode, False for 24h mode. + + """ + mode_byte = "80" if is_12h_mode else "00" + + payload = COMMAND_SET_DISPLAY_FORMAT + mode_byte + result = await self._send_command(payload) + self._validate_result("set_time_display_format", result) + + def _validate_result( + self, op_name: str, result: bytes | None, min_length: int | None = None + ) -> bytes: + if not self._check_command_result(result, 0, {1}): + raise SwitchbotOperationError( + f"{self.name}: Unexpected response code for {op_name} (result={result.hex() if result else 'None'} rssi={self.rssi})" + ) + assert result is not None + if min_length is not None and len(result) < min_length: + raise SwitchbotOperationError( + f"{self.name}: Unexpected response len for {op_name}, wanted at least {min_length} (result={result.hex() if result else 'None'} rssi={self.rssi})" + ) + return result diff --git a/tests/test_meter.py b/tests/test_meter_pro.py similarity index 98% rename from tests/test_meter.py rename to tests/test_meter_pro.py index a06737d4..3cafc286 100644 --- a/tests/test_meter.py +++ b/tests/test_meter_pro.py @@ -5,7 +5,7 @@ from bleak.backends.device import BLEDevice from switchbot import SwitchbotOperationError -from switchbot.devices.meter import MAX_TIME_OFFSET, SwitchbotMeterProCO2 +from switchbot.devices.meter_pro import MAX_TIME_OFFSET, SwitchbotMeterProCO2 def create_device(): From e97bc40fb84a57e9bc3df43562fc056abee9cd0a Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:49:37 +0100 Subject: [PATCH 15/21] Addresses code review comments: makes set_datetime low-level, adds return types. Also makes tests parameterized to remove code repetition. --- switchbot/devices/meter_pro.py | 46 ++++++++------ tests/test_meter_pro.py | 109 +++++++++++++++++++-------------- 2 files changed, 91 insertions(+), 64 deletions(-) diff --git a/switchbot/devices/meter_pro.py b/switchbot/devices/meter_pro.py index bbc22105..48e5466d 100644 --- a/switchbot/devices/meter_pro.py +++ b/switchbot/devices/meter_pro.py @@ -1,11 +1,9 @@ -from datetime import datetime - from ..helpers import parse_uint24_be from .device import SwitchbotDevice, SwitchbotOperationError COMMAND_SET_TIME_OFFSET = "570f680506" COMMAND_GET_TIME_OFFSET = "570f690506" -MAX_TIME_OFFSET = 1 << 24 - 1 +MAX_TIME_OFFSET = (1 << 24) - 1 COMMAND_GET_DEVICE_DATETIME = "570f6901" COMMAND_SET_DEVICE_DATETIME = "57000503" @@ -35,7 +33,7 @@ async def get_time_offset(self) -> int: offset = parse_uint24_be(result, 2) return -offset if is_negative else offset - async def set_time_offset(self, offset_seconds: int): + async def set_time_offset(self, offset_seconds: int) -> None: """ Set the display time offset on the device. This is what happens when you adjust display time in the Switchbot app. The displayed time is @@ -99,28 +97,38 @@ async def get_datetime(self) -> dict: "second": result[12], } - async def set_datetime(self, dt: datetime): + async def set_datetime( + self, timestamp: int, utc_offset_hours: int = 0, utc_offset_minutes: int = 0 + ) -> None: """ Set the device internal time and timezone. Similar to how the Switchbot app does it upon syncing with the device. + Pay attention to calculating UTC offset hours and minutes, see + examples below. Args: - dt (datetime): datetime object with timezone information. + timestamp (int): Unix timestamp in seconds. + utc_offset_hours (int): UTC offset in hours, floor()'ed, + within [-12; 14] range. + Examples: -5 for UTC-05:00, + -6 for UTC-05:30, 5 for UTC+05:00, + 5 for UTC+5:30. + utc_offset_minutes (int): UTC offset minutes component, always + positive complement to utc_offset_hours. + Examples: 45 for UTC+05:45, 15 for UTC-5:45. """ - utc_offset = dt.utcoffset() - if utc_offset is None: - # Fallback to the local timezone. - utc_offset = datetime.now().astimezone().utcoffset() - utc_offset_hours, utc_offset_minutes = 0, 0 - if utc_offset is not None: - total_minutes = int(utc_offset.total_seconds() // 60) - # UTC-04:30 tz is represented as -5hrs +30min - utc_offset_hours, utc_offset_minutes = divmod(total_minutes, 60) - # The device doesn't automatically add offset minutes, it expects them # to come as a part of the timestamp. - timestamp = int(dt.timestamp()) + utc_offset_minutes * 60 + if not (-12 <= utc_offset_hours <= 14): + raise SwitchbotOperationError( + f"{self.name}: utc_offset_hours must be between -12 and +14 inclusive, got {utc_offset_hours}" + ) + if not (0 <= utc_offset_minutes <= 60): + raise SwitchbotOperationError( + f"{self.name}: utc_offset_minutes must be between 0 and 60 inclusive, got {utc_offset_minutes}" + ) + adjusted_timestamp = timestamp + utc_offset_minutes * 60 # The timezone is encoded as 1 byte, where 00 stands for UTC-12. # TZ with minute offset gets floor()ed: 4:30 yields 4, -4:30 yields -5. @@ -129,14 +137,14 @@ async def set_datetime(self, dt: datetime): payload = ( COMMAND_SET_DEVICE_DATETIME + f"{utc_byte:02x}" - + f"{timestamp:016x}" + + f"{adjusted_timestamp:016x}" + f"{utc_offset_minutes:02x}" ) result = await self._send_command(payload) self._validate_result("set_datetime", result) - async def set_time_display_format(self, is_12h_mode: bool = False): + async def set_time_display_format(self, is_12h_mode: bool = False) -> None: """ Set the time display format on the device: 12h(AM/PM) or 24h. diff --git a/tests/test_meter_pro.py b/tests/test_meter_pro.py index 3cafc286..56458f0f 100644 --- a/tests/test_meter_pro.py +++ b/tests/test_meter_pro.py @@ -1,4 +1,3 @@ -from datetime import datetime from unittest.mock import AsyncMock import pytest @@ -164,69 +163,89 @@ async def test_get_datetime_wrong_response(): @pytest.mark.asyncio -async def test_set_datetime_iso_utc(): +@pytest.mark.parametrize( + ( + "timestamp", + "utc_offset_hours", + "utc_offset_minutes", + "expected_ts", + "expected_utc", + "expected_min", + ), + [ + (1709251200, 0, 0, "0000000065e11a80", "0c", "00"), # 2024-03-01T00:00:00+00:00 + (1709251200, 1, 0, "0000000065e11a80", "0d", "00"), # 2024-03-01T00:00:00+01:00 + ( + 1709251200, + 5, + 45, + "0000000065e1250c", + "11", + "2d", + ), # 2024-03-01T00:00:00+05:45 + ( + 1709251200, + -6, + 15, + "0000000065e11e04", + "06", + "0f", + ), # 2024-03-01T00:00:00-05:45 + ], +) +async def test_set_datetime_iso( # noqa: PLR0913 + timestamp, + utc_offset_hours, + utc_offset_minutes, + expected_utc, + expected_ts, + expected_min, +): device = create_device() device._send_command.return_value = bytes.fromhex("01") - # "2024-03-01T00:00:00+00:00" -> Timestamp 1709251200. Offset 0. - await device.set_datetime(datetime.fromisoformat("2024-03-01T00:00:00+00:00")) - - expected_header = "57000503" - expected_utc = "0c" - expected_ts = "0000000065e11a80" - expected_min = "00" - - expected_payload = expected_header + expected_utc + expected_ts + expected_min - device._send_command.assert_called_with(expected_payload) - - -@pytest.mark.asyncio -async def test_set_datetime_iso_offset(): - device = create_device() - device._send_command.return_value = bytes.fromhex("01") - - # "2024-03-01T01:00:00+01:00" -> Timestamp 1709251200. Offset +1h. - await device.set_datetime(datetime.fromisoformat("2024-03-01T01:00:00+01:00")) + await device.set_datetime( + timestamp, + utc_offset_hours=utc_offset_hours, + utc_offset_minutes=utc_offset_minutes, + ) - expected_header = "57000503" - expected_utc = "0d" - expected_ts = "0000000065e11a80" - expected_min = "00" - expected_payload = expected_header + expected_utc + expected_ts + expected_min + expected_payload = "57000503" + expected_utc + expected_ts + expected_min device._send_command.assert_called_with(expected_payload) @pytest.mark.asyncio -async def test_set_datetime_iso_irregular(): +async def test_set_datetime_invalid_utc_offset_hours(): device = create_device() - device._send_command.return_value = bytes.fromhex("01") - - await device.set_datetime(datetime.fromisoformat("2024-03-01T05:30:00+05:45")) - - expected_header = "57000503" - expected_utc = "11" - expected_ts = "0000000065e12188" - expected_min = "2d" - expected_payload = expected_header + expected_utc + expected_ts + expected_min - device._send_command.assert_called_with(expected_payload) + # Hours outside allowed range should raise + for bad_hour in (-13, 15): + with pytest.raises(SwitchbotOperationError): + await device.set_datetime(1709251200, utc_offset_hours=bad_hour) @pytest.mark.asyncio -async def test_set_time_display_format_12h(): +async def test_set_datetime_invalid_utc_offset_minutes(): device = create_device() - device._send_command.return_value = bytes.fromhex("01") - - await device.set_time_display_format(is_12h_mode=True) - device._send_command.assert_called_with("570f68050580") + # Minutes outside allowed range should raise + for bad_min in (-1, 61): + with pytest.raises(SwitchbotOperationError): + await device.set_datetime(1709251200, utc_offset_minutes=bad_min) @pytest.mark.asyncio -async def test_set_time_display_format_24h(): +@pytest.mark.parametrize( + ("is_12h_mode", "expected_cmd"), + [ + (True, "570f68050580"), + (False, "570f68050500"), + ], +) +async def test_set_time_display_format(is_12h_mode, expected_cmd): device = create_device() device._send_command.return_value = bytes.fromhex("01") - await device.set_time_display_format(is_12h_mode=False) - device._send_command.assert_called_with("570f68050500") + await device.set_time_display_format(is_12h_mode=is_12h_mode) + device._send_command.assert_called_with(expected_cmd) @pytest.mark.asyncio From 9b9d4edfe078398f6a524e4e0cd4522637d19a51 Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:38:43 +0100 Subject: [PATCH 16/21] Fixes formatting, comment location and turns more tests into parameterized ones. --- switchbot/devices/meter_pro.py | 18 ++--- tests/test_meter_pro.py | 125 +++++++++++++++------------------ 2 files changed, 67 insertions(+), 76 deletions(-) diff --git a/switchbot/devices/meter_pro.py b/switchbot/devices/meter_pro.py index 48e5466d..490dbe15 100644 --- a/switchbot/devices/meter_pro.py +++ b/switchbot/devices/meter_pro.py @@ -51,7 +51,7 @@ async def set_time_offset(self, offset_seconds: int) -> None: f"{self.name}: Requested to set_time_offset of {offset_seconds} seconds, allowed +-{MAX_TIME_OFFSET} max." ) - sign_byte = "80" if offset_seconds < 0 else "00" + sign_byte = "80" if offset_seconds <= 0 else "00" # Example: 57-0f-68-05-06-80-00-10-00 -> subtract 4096 seconds. payload = COMMAND_SET_TIME_OFFSET + sign_byte + f"{abs_offset:06x}" @@ -109,17 +109,14 @@ async def set_datetime( Args: timestamp (int): Unix timestamp in seconds. utc_offset_hours (int): UTC offset in hours, floor()'ed, - within [-12; 14] range. - Examples: -5 for UTC-05:00, - -6 for UTC-05:30, 5 for UTC+05:00, - 5 for UTC+5:30. + within [-12; 14] range. + Examples: -5 for UTC-05:00, -6 for UTC-05:30, + 5 for UTC+05:00, 5 for UTC+5:30. utc_offset_minutes (int): UTC offset minutes component, always - positive complement to utc_offset_hours. - Examples: 45 for UTC+05:45, 15 for UTC-5:45. + positive, complements utc_offset_hours. + Examples: 45 for UTC+05:45, 15 for UTC-5:45. """ - # The device doesn't automatically add offset minutes, it expects them - # to come as a part of the timestamp. if not (-12 <= utc_offset_hours <= 14): raise SwitchbotOperationError( f"{self.name}: utc_offset_hours must be between -12 and +14 inclusive, got {utc_offset_hours}" @@ -128,6 +125,9 @@ async def set_datetime( raise SwitchbotOperationError( f"{self.name}: utc_offset_minutes must be between 0 and 60 inclusive, got {utc_offset_minutes}" ) + + # The device doesn't automatically add offset minutes, it expects them + # to come as a part of the timestamp. adjusted_timestamp = timestamp + utc_offset_minutes * 60 # The timezone is encoded as 1 byte, where 00 stands for UTC-12. diff --git a/tests/test_meter_pro.py b/tests/test_meter_pro.py index 56458f0f..bd176a83 100644 --- a/tests/test_meter_pro.py +++ b/tests/test_meter_pro.py @@ -17,25 +17,23 @@ def create_device(): @pytest.mark.asyncio -async def test_get_time_offset_positive(): - device = create_device() - # Mock response: 01 (success) 00 (plus offset) 10 1b c9 (1055689 seconds) - device._send_command.return_value = bytes.fromhex("0100101bc9") - - offset = await device.get_time_offset() - device._send_command.assert_called_with("570f690506") - assert offset == 1055689 - - -@pytest.mark.asyncio -async def test_get_time_offset_negative(): +@pytest.mark.parametrize( + ( + "device_response", + "expected_offset", + ), + [ + ("0100101bc9", 1055689), # 01 (success) 00 (plus offset) 10 1b c9 (1055689) + ("0180101bc9", -1055689), # 01 (success) 80 (minus offset) 10 1b c9 (1055689) + ], +) +async def test_get_time_offset(device_response: str, expected_offset: int): device = create_device() - # Mock response: 01 (success) 80 (minus offset) 10 1b c9 (1055689 seconds) - device._send_command.return_value = bytes.fromhex("0180101bc9") + device._send_command.return_value = bytes.fromhex(device_response) offset = await device.get_time_offset() device._send_command.assert_called_with("570f690506") - assert offset == -1055689 + assert offset == expected_offset @pytest.mark.asyncio @@ -60,21 +58,23 @@ async def test_get_time_offset_wrong_response(): @pytest.mark.asyncio -async def test_set_time_offset_positive(): - device = create_device() - device._send_command.return_value = bytes.fromhex("01") - - await device.set_time_offset(1055689) - device._send_command.assert_called_with("570f68050600101bc9") - - -@pytest.mark.asyncio -async def test_set_time_offset_negative(): +@pytest.mark.parametrize( + ( + "offset_sec", + "expected_payload", + ), + [ + (1055689, "00101bc9"), # "00" for positive offset, 101bc9 for 1055689 + (-4096, "80001000"), # "80" for negative offset, 001000 for 4097 + (0, "80000000"), + ], +) +async def test_set_time_offset(offset_sec: int, expected_payload: str): device = create_device() device._send_command.return_value = bytes.fromhex("01") - await device.set_time_offset(-4096) - device._send_command.assert_called_with("570f68050680001000") + await device.set_time_offset(offset_sec) + device._send_command.assert_called_with("570f680506" + expected_payload) @pytest.mark.asyncio @@ -173,33 +173,19 @@ async def test_get_datetime_wrong_response(): "expected_min", ), [ - (1709251200, 0, 0, "0000000065e11a80", "0c", "00"), # 2024-03-01T00:00:00+00:00 - (1709251200, 1, 0, "0000000065e11a80", "0d", "00"), # 2024-03-01T00:00:00+01:00 - ( - 1709251200, - 5, - 45, - "0000000065e1250c", - "11", - "2d", - ), # 2024-03-01T00:00:00+05:45 - ( - 1709251200, - -6, - 15, - "0000000065e11e04", - "06", - "0f", - ), # 2024-03-01T00:00:00-05:45 + (1709251200, 0, 0, "65e11a80", "0c", "00"), # 2024-03-01T00:00:00+00:00 + (1709251200, 1, 0, "65e11a80", "0d", "00"), # 2024-03-01T00:00:00+01:00 + (1709251200, 5, 45, "65e1250c", "11", "2d"), # 2024-03-01T00:00:00+05:45 + (1709251200, -6, 15, "65e11e04", "06", "0f"), # 2024-03-01T00:00:00-05:45 ], ) -async def test_set_datetime_iso( # noqa: PLR0913 - timestamp, - utc_offset_hours, - utc_offset_minutes, - expected_utc, - expected_ts, - expected_min, +async def test_set_datetime( # noqa: PLR0913 + timestamp: int, + utc_offset_hours: int, + utc_offset_minutes: int, + expected_ts: str, + expected_utc: str, + expected_min: str, ): device = create_device() device._send_command.return_value = bytes.fromhex("01") @@ -210,42 +196,47 @@ async def test_set_datetime_iso( # noqa: PLR0913 utc_offset_minutes=utc_offset_minutes, ) + expected_ts = expected_ts.zfill(16) expected_payload = "57000503" + expected_utc + expected_ts + expected_min device._send_command.assert_called_with(expected_payload) @pytest.mark.asyncio -async def test_set_datetime_invalid_utc_offset_hours(): +@pytest.mark.parametrize( + "bad_hour", + [-13, 15], +) +async def test_set_datetime_invalid_utc_offset_hours(bad_hour: int): device = create_device() - # Hours outside allowed range should raise - for bad_hour in (-13, 15): - with pytest.raises(SwitchbotOperationError): - await device.set_datetime(1709251200, utc_offset_hours=bad_hour) + with pytest.raises(SwitchbotOperationError): + await device.set_datetime(1709251200, utc_offset_hours=bad_hour) @pytest.mark.asyncio -async def test_set_datetime_invalid_utc_offset_minutes(): +@pytest.mark.parametrize( + "bad_min", + [-1, 61], +) +async def test_set_datetime_invalid_utc_offset_minutes(bad_min: int): device = create_device() - # Minutes outside allowed range should raise - for bad_min in (-1, 61): - with pytest.raises(SwitchbotOperationError): - await device.set_datetime(1709251200, utc_offset_minutes=bad_min) + with pytest.raises(SwitchbotOperationError): + await device.set_datetime(1709251200, utc_offset_minutes=bad_min) @pytest.mark.asyncio @pytest.mark.parametrize( - ("is_12h_mode", "expected_cmd"), + ("is_12h_mode", "expected_payload"), [ - (True, "570f68050580"), - (False, "570f68050500"), + (True, "80"), + (False, "00"), ], ) -async def test_set_time_display_format(is_12h_mode, expected_cmd): +async def test_set_time_display_format(is_12h_mode: bool, expected_payload: str): device = create_device() device._send_command.return_value = bytes.fromhex("01") await device.set_time_display_format(is_12h_mode=is_12h_mode) - device._send_command.assert_called_with(expected_cmd) + device._send_command.assert_called_with("570f680505" + expected_payload) @pytest.mark.asyncio From 38f8994217bdb34501b963c64fee1fdc5e76388c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 25 Jan 2026 09:57:06 -1000 Subject: [PATCH 17/21] avoid mixing concat + format strings --- switchbot/devices/meter_pro.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/switchbot/devices/meter_pro.py b/switchbot/devices/meter_pro.py index 490dbe15..eb7c43b7 100644 --- a/switchbot/devices/meter_pro.py +++ b/switchbot/devices/meter_pro.py @@ -54,7 +54,7 @@ async def set_time_offset(self, offset_seconds: int) -> None: sign_byte = "80" if offset_seconds <= 0 else "00" # Example: 57-0f-68-05-06-80-00-10-00 -> subtract 4096 seconds. - payload = COMMAND_SET_TIME_OFFSET + sign_byte + f"{abs_offset:06x}" + payload = f"{COMMAND_SET_TIME_OFFSET}{sign_byte}{abs_offset:06x}" result = await self._send_command(payload) self._validate_result("set_time_offset", result) @@ -135,10 +135,8 @@ async def set_datetime( utc_byte = utc_offset_hours + 12 payload = ( - COMMAND_SET_DEVICE_DATETIME - + f"{utc_byte:02x}" - + f"{adjusted_timestamp:016x}" - + f"{utc_offset_minutes:02x}" + f"{COMMAND_SET_DEVICE_DATETIME}{utc_byte:02x}" + f"{adjusted_timestamp:016x}{utc_offset_minutes:02x}" ) result = await self._send_command(payload) @@ -153,8 +151,7 @@ async def set_time_display_format(self, is_12h_mode: bool = False) -> None: """ mode_byte = "80" if is_12h_mode else "00" - - payload = COMMAND_SET_DISPLAY_FORMAT + mode_byte + payload = f"{COMMAND_SET_DISPLAY_FORMAT}{mode_byte}" result = await self._send_command(payload) self._validate_result("set_time_display_format", result) From 44e56283886b47be7e04ac649c6c4c0a93b6bd73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 25 Jan 2026 09:59:12 -1000 Subject: [PATCH 18/21] fix utc_offset_minutes bug --- switchbot/devices/meter_pro.py | 4 ++-- tests/test_meter_pro.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/switchbot/devices/meter_pro.py b/switchbot/devices/meter_pro.py index eb7c43b7..0b458074 100644 --- a/switchbot/devices/meter_pro.py +++ b/switchbot/devices/meter_pro.py @@ -121,9 +121,9 @@ async def set_datetime( raise SwitchbotOperationError( f"{self.name}: utc_offset_hours must be between -12 and +14 inclusive, got {utc_offset_hours}" ) - if not (0 <= utc_offset_minutes <= 60): + if not (0 <= utc_offset_minutes < 60): raise SwitchbotOperationError( - f"{self.name}: utc_offset_minutes must be between 0 and 60 inclusive, got {utc_offset_minutes}" + f"{self.name}: utc_offset_minutes must be between 0 and 59 inclusive, got {utc_offset_minutes}" ) # The device doesn't automatically add offset minutes, it expects them diff --git a/tests/test_meter_pro.py b/tests/test_meter_pro.py index bd176a83..b58dd1da 100644 --- a/tests/test_meter_pro.py +++ b/tests/test_meter_pro.py @@ -215,7 +215,7 @@ async def test_set_datetime_invalid_utc_offset_hours(bad_hour: int): @pytest.mark.asyncio @pytest.mark.parametrize( "bad_min", - [-1, 61], + [-1, 60], ) async def test_set_datetime_invalid_utc_offset_minutes(bad_min: int): device = create_device() From 7404e586015ea1fbcd03e2cff9a996118c21da08 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 25 Jan 2026 09:59:39 -1000 Subject: [PATCH 19/21] typing --- switchbot/devices/meter_pro.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/switchbot/devices/meter_pro.py b/switchbot/devices/meter_pro.py index 0b458074..737dc13e 100644 --- a/switchbot/devices/meter_pro.py +++ b/switchbot/devices/meter_pro.py @@ -1,3 +1,5 @@ +from typing import Any + from ..helpers import parse_uint24_be from .device import SwitchbotDevice, SwitchbotOperationError @@ -58,7 +60,7 @@ async def set_time_offset(self, offset_seconds: int) -> None: result = await self._send_command(payload) self._validate_result("set_time_offset", result) - async def get_datetime(self) -> dict: + async def get_datetime(self) -> dict[str, Any]: """ Get the current device time and settings as it is displayed. Contains a time offset, if any was applied (see COMMAND_TIME_OFFSET). From b4eb33fa7387e7328092337e12417ba219c279d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 25 Jan 2026 10:01:25 -1000 Subject: [PATCH 20/21] tweak 0 handing --- switchbot/devices/meter_pro.py | 2 +- tests/test_meter_pro.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/switchbot/devices/meter_pro.py b/switchbot/devices/meter_pro.py index 737dc13e..c6241356 100644 --- a/switchbot/devices/meter_pro.py +++ b/switchbot/devices/meter_pro.py @@ -53,7 +53,7 @@ async def set_time_offset(self, offset_seconds: int) -> None: f"{self.name}: Requested to set_time_offset of {offset_seconds} seconds, allowed +-{MAX_TIME_OFFSET} max." ) - sign_byte = "80" if offset_seconds <= 0 else "00" + sign_byte = "80" if offset_seconds < 0 else "00" # Example: 57-0f-68-05-06-80-00-10-00 -> subtract 4096 seconds. payload = f"{COMMAND_SET_TIME_OFFSET}{sign_byte}{abs_offset:06x}" diff --git a/tests/test_meter_pro.py b/tests/test_meter_pro.py index b58dd1da..e988d479 100644 --- a/tests/test_meter_pro.py +++ b/tests/test_meter_pro.py @@ -65,8 +65,9 @@ async def test_get_time_offset_wrong_response(): ), [ (1055689, "00101bc9"), # "00" for positive offset, 101bc9 for 1055689 - (-4096, "80001000"), # "80" for negative offset, 001000 for 4097 - (0, "80000000"), + (-4096, "80001000"), # "80" for negative offset, 001000 for 4096 + (0, "00000000"), + (-0, "00000000"), # -0 == 0 in Python ], ) async def test_set_time_offset(offset_sec: int, expected_payload: str): From afa17190d2aa6cfb3573166437a9a4c0fa80af1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 25 Jan 2026 10:04:13 -1000 Subject: [PATCH 21/21] address remaining comments from bot --- switchbot/devices/meter_pro.py | 2 +- tests/test_meter_pro.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/switchbot/devices/meter_pro.py b/switchbot/devices/meter_pro.py index c6241356..4f4fbbda 100644 --- a/switchbot/devices/meter_pro.py +++ b/switchbot/devices/meter_pro.py @@ -63,7 +63,7 @@ async def set_time_offset(self, offset_seconds: int) -> None: async def get_datetime(self) -> dict[str, Any]: """ Get the current device time and settings as it is displayed. Contains - a time offset, if any was applied (see COMMAND_TIME_OFFSET). + a time offset, if any was applied (see set_time_offset). Doesn't include the current time zone. Returns: diff --git a/tests/test_meter_pro.py b/tests/test_meter_pro.py index e988d479..449a8636 100644 --- a/tests/test_meter_pro.py +++ b/tests/test_meter_pro.py @@ -50,7 +50,7 @@ async def test_get_time_offset_failure(): @pytest.mark.asyncio async def test_get_time_offset_wrong_response(): device = create_device() - # Invalid 1st byte + # Response too short (only status byte returned) device._send_command.return_value = bytes.fromhex("01") with pytest.raises(SwitchbotOperationError):