diff --git a/switchbot/devices/meter_pro.py b/switchbot/devices/meter_pro.py index 4f4fbbda..fe340657 100644 --- a/switchbot/devices/meter_pro.py +++ b/switchbot/devices/meter_pro.py @@ -3,17 +3,39 @@ from ..helpers import parse_uint24_be from .device import SwitchbotDevice, SwitchbotOperationError -COMMAND_SET_TIME_OFFSET = "570f680506" +SETTINGS_HEADER = "570f68" +COMMAND_SHOW_BATTERY_LEVEL = f"{SETTINGS_HEADER}070108" +COMMAND_DATE_FORMAT = f"{SETTINGS_HEADER}070107" + +COMMAND_TEMPERATURE_UPDATE_INTERVAL = f"{SETTINGS_HEADER}070105" +COMMAND_CO2_UPDATE_INTERVAL = f"{SETTINGS_HEADER}0b06" +COMMAND_FORCE_NEW_CO2_MEASUREMENT = f"{SETTINGS_HEADER}0b04" +COMMAND_CO2_THRESHOLDS = f"{SETTINGS_HEADER}020302" +COMMAND_COMFORTLEVEL = f"{SETTINGS_HEADER}020188" + +COMMAND_BUTTON_FUNCTION = f"{SETTINGS_HEADER}070106" +COMMAND_CALIBRATE_CO2_SENSOR = f"{SETTINGS_HEADER}0b02" + +COMMAND_ALERT_SOUND = f"{SETTINGS_HEADER}0204" +COMMAND_ALERT_TEMPERATURE_HUMIDITY = "570f44" +COMMAND_ALERT_CO2 = f"{SETTINGS_HEADER}020301" + +COMMAND_SET_TIME_OFFSET = f"{SETTINGS_HEADER}0506" 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" +COMMAND_SET_DISPLAY_FORMAT = f"{SETTINGS_HEADER}0505" class SwitchbotMeterProCO2(SwitchbotDevice): - """API to control Switchbot Meter Pro CO2.""" + """ + API to control Switchbot Meter Pro CO2. + + The assumptions that the original app has for each value are noted at the respective method. + Which of them are actually required by the device is unknown. + """ async def get_time_offset(self) -> int: """ @@ -157,6 +179,235 @@ async def set_time_display_format(self, is_12h_mode: bool = False) -> None: result = await self._send_command(payload) self._validate_result("set_time_display_format", result) + async def show_battery_level(self, show_battery: bool): + """Show or hide battery level on the display.""" + show_battery_byte = "01" if show_battery else "00" + await self._send_command(COMMAND_SHOW_BATTERY_LEVEL + show_battery_byte) + + async def set_co2_thresholds(self, lower: int, upper: int): + """ + Sets the thresholds to define Air Quality for depiction on display as follows: + co2 < lower => Good (Green) + lower < co2 < upper => Moderate (Orange) + upper < co2 => Poor (Red) + + Original App assumes: + 500 <= lower < upper <= 1900 + lower and upper are multiples of 100 + """ + if lower >= upper: + raise ValueError("Lower should be smaller than upper") + await self._send_command( + COMMAND_CO2_THRESHOLDS + f"{lower:04x}" + f"{upper:04x}" + ) + + async def set_comfortlevel(self, cold: float, hot: float, dry: int, wet: int): + """ + Sets the Thresholds for comfortable temperature (in C) and humidity to display comfort-level. + The supported values in the original App are as following: + Temperature is -20C to 80C in 0.5C steps + Humidity is 1% to 99% in 1% steps + """ + if cold >= hot: + raise ValueError("Cold should be smaller than Hot") + if dry >= wet: + raise ValueError("Dry should be smaller than Wet") + + point_five = self._get_point_five_byte(cold, hot) + cold_byte = self._encode_temperature(int(cold)) + hot_byte = self._encode_temperature(int(hot)) + + await self._send_command( + COMMAND_COMFORTLEVEL + + hot_byte + + f"{wet:02x}" + + point_five + + cold_byte + + f"{dry:02x}" + ) + + async def set_alert_temperature_humidity( + self, + temperature_alert: bool = False, + temperature_low: float = -20.0, + temperature_high: float = 80.0, + temperature_reverse: bool = False, + humidity_alert: bool = False, + humidity_low: int = 1, + humidity_high: int = 99, + humidity_reverse: bool = False, + absolute_humidity_alert: bool = False, + absolute_humidity_low: float = 0.00, + absolute_humidity_high: float = 99.99, + absolute_humidity_reverse: bool = False, + dewpoint_alert: bool = False, + dewpoint_low: float = -60.0, + dewpoint_high: float = 60.0, + dewpoint_reverse: bool = False, + vpd_alert: bool = False, + vpd_low: float = 0.00, + vpd_high: float = 10.00, + vpd_reverse: bool = False, + ): + """ + Sets Temperature- and Humidity- related alerts. + *_alert: enable or disable respective alert + *_low and *_high: The respective ranges + *_reverse: If False: Alert if measured value is outside of provided range. + If True: Alert if measured value is inside of provided range. + + The range-boundaries have different assumptions in the original App: + Temperature: Between -20℃ and 80℃ in 0.5℃ steps + Humidity: Between 1% and 99% in 1% steps + Absolute Humidity: Between 0.00g/m^3 and 99.99g/m^3 in 0.5g/m^3 steps (99.99 instead of 100) + Dew Point: Between -60℃ and 60℃ in 0.5℃ steps + VPD: Between 0 kPa and 10 kPa in 0.05 kPa steps + """ + mode_temp_humid = 0x00 + if temperature_alert: + if temperature_reverse: + mode_temp_humid += 0x04 + else: + mode_temp_humid += 0x03 + if humidity_alert: + if humidity_reverse: + mode_temp_humid += 0x40 + else: + mode_temp_humid += 0x30 + + mode_abshumid_dewpoint_vpd = 0x00 + if absolute_humidity_alert: + if absolute_humidity_reverse: + mode_abshumid_dewpoint_vpd += 0x02 + else: + mode_abshumid_dewpoint_vpd += 0x01 + if dewpoint_alert: + if dewpoint_reverse: + mode_abshumid_dewpoint_vpd += 0x10 + else: + mode_abshumid_dewpoint_vpd += 0x0C + if vpd_alert: + if vpd_reverse: + mode_abshumid_dewpoint_vpd += 0x80 + else: + mode_abshumid_dewpoint_vpd += 0x60 + + temperature_point_five = self._get_point_five_byte( + temperature_low, temperature_high + ) + temperature_low_byte = self._encode_temperature(int(temperature_low)) + temperature_high_byte = self._encode_temperature(int(temperature_high)) + + dewpoint_point_five = self._get_point_five_byte(dewpoint_low, dewpoint_high) + dewpoint_low_byte = self._encode_temperature(int(dewpoint_low)) + dewpoint_high_byte = self._encode_temperature(int(dewpoint_high)) + + absolute_humidity_low_bytes = ( + f"{int(absolute_humidity_low):02x}" + + f"{int(absolute_humidity_low * 100 % 100):02x}" + ) + absolute_humidity_high_bytes = ( + f"{int(absolute_humidity_high):02x}" + + f"{int(absolute_humidity_high * 100 % 100):02x}" + ) + + vpd_bytes = ( + f"{int(vpd_high * 100 % 100):02x}" + + f"{int(vpd_low * 100 % 100):02x}" + + f"{int(vpd_high):01x}" + ) + f"{int(vpd_low):01x}" + + await self._send_command( + COMMAND_ALERT_TEMPERATURE_HUMIDITY + + f"{mode_temp_humid:02x}" + + temperature_high_byte + + f"{humidity_high:02x}" + + temperature_point_five + + temperature_low_byte + + f"{humidity_low:02x}" + + dewpoint_high_byte + + dewpoint_point_five + + dewpoint_low_byte + + vpd_bytes + + f"{mode_abshumid_dewpoint_vpd:02x}" + + absolute_humidity_high_bytes + + absolute_humidity_low_bytes + ) + + async def set_alert_co2(self, on: bool, co2_low: int, co2_high: int, reverse: bool): + """ + Sets the CO2-Alert. + on: Turn CO2-Alert on or off + lower and upper: The provided range (between 400ppm and 2000ppm in 100ppm steps) + reverse: If False: Alert if measured value is outside of provided range. + If True: Alert if measured value is inside of provided range. + """ + if co2_high < co2_low: + raise ValueError( + "Upper value should bigger than the lower value. Do you want to use reverse instead?" + ) + + mode = 0x00 if not on else (0x04 if reverse else 0x03) + await self._send_command( + COMMAND_ALERT_CO2 + f"{mode:02x}" + f"{co2_high:04x}" + f"{co2_low:04x}" + ) + + async def set_temperature_update_interval(self, minutes: int): + """ + Sets the interval in which temperature and humidity are measured in battery powered mode. + Original App assumes minutes in {5, 10, 30} + """ + seconds = minutes * 60 + await self._send_command(COMMAND_TEMPERATURE_UPDATE_INTERVAL + f"{seconds:04x}") + + async def set_co2_update_interval(self, minutes: int): + """ + Sets the interval in which co2 levels are measured in battery powered mode. + Original App assumes minutes in {5, 10, 30} + """ + seconds = minutes * 60 + await self._send_command(COMMAND_CO2_UPDATE_INTERVAL + f"{seconds:04x}") + + async def set_button_function(self, change_unit: bool, change_data_source: bool): + """ + Sets the function of the top button: + Default (both options false): Only update data + changeUnit: switch between ℃ and ℉ + changeDataSource: switch between display of indoor and outdoor temperature + """ + change_unit_byte = ( + "00" if change_unit else "01" + ) # yes, it has to be reversed like this! + change_data_source_byte = ( + "01" if change_data_source else "00" + ) # yes, it has to be reversed like this! + await self._send_command( + COMMAND_BUTTON_FUNCTION + change_unit_byte + change_data_source_byte + ) + + async def force_new_co2_measurement(self): + """Requests a new CO2 measurement, regardless of update interval""" + await self._send_command(COMMAND_FORCE_NEW_CO2_MEASUREMENT) + + async def calibrate_co2_sensor(self): + """ + Calibrate CO2-Sensor. + Place your device in a well-ventilated area for 1 minute before calling this. + After calling this the calibration runs for about 5 minutes. + Keep the device still during this process. + """ + await self._send_command(COMMAND_CALIBRATE_CO2_SENSOR) + + async def set_alert_sound(self, sound_on: bool, volume: int): + """ + Sets the Alert-Mode. + If soundOn is False the display flashes. + If soundOn is True the device additionally beeps. + The volume is expected to be in {2,3,4} (2: low, 3: medium, 4: high) + """ + sound_on_byte = "02" if sound_on else "01" + await self._send_command(COMMAND_ALERT_SOUND + f"{volume:02x}" + sound_on_byte) + def _validate_result( self, op_name: str, result: bytes | None, min_length: int | None = None ) -> bytes: @@ -170,3 +421,21 @@ def _validate_result( 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 + + def _get_point_five_byte(self, cold: float, hot: float): + """Represents if either of the temperatures has a .5 decimalplace""" + point_five = 0x00 + if int(cold * 10) % 10 == 5: + point_five += 0x05 + if int(hot * 10) % 10 == 5: + point_five += 0x50 + return f"{point_five:02x}" + + def _encode_temperature(self, temp: int): + # The encoding for a negative temperature is the value as hex + # The encoding for a positive temperature is the value + 128 as hex + if temp > 0: + temp += 128 + else: + temp *= -1 + return f"{temp:02x}" diff --git a/tests/test_meter_pro.py b/tests/test_meter_pro.py index 449a8636..21bf24b9 100644 --- a/tests/test_meter_pro.py +++ b/tests/test_meter_pro.py @@ -247,3 +247,570 @@ async def test_set_time_display_format_failure(): with pytest.raises(SwitchbotOperationError): await device.set_time_display_format(is_12h_mode=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("show_battery", "expected_payload"), + [ + (True, "01"), + (False, "00"), + ], +) +async def test_show_battery_level(show_battery: bool, expected_payload: str): + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.show_battery_level(show_battery=show_battery) + device._send_command.assert_called_with("570f68070108" + expected_payload) + + +@pytest.mark.asyncio +async def test_set_co2_thresholds(): + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_co2_thresholds(lower=500, upper=1000) + device._send_command.assert_called_with("570f6802030201f403e8") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("cold", "hot", "dry", "wet", "expected_payload"), + [ + (10.0, 20.0, 40, 80, "9450008a28"), + (-20.0, -10.0, 40, 80, "0a50001428"), + (-20.0, 70, 40, 80, "c650001428"), + (0.5, 22, 40, 82, "9652050028"), + (0, 22, 40, 82, "9652000028"), + (14, 37.5, 30, 70, "a546508e1e"), + ], +) +async def test_set_comfortlevel( + cold: float, hot: float, dry: int, wet: int, expected_payload: str +): + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_comfortlevel(cold, hot, dry, wet) + device._send_command.assert_called_with("570f68020188" + expected_payload) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ( + "temperature_alert", + "temperature_low", + "temperature_high", + "temperature_reverse", + "humidity_alert", + "humidity_low", + "humidity_high", + "humidity_reverse", + "absolute_humidity_alert", + "absolute_humidity_low", + "absolute_humidity_high", + "absolute_humidity_reverse", + "dewpoint_alert", + "dewpoint_low", + "dewpoint_high", + "dewpoint_reverse", + "vpd_alert", + "vpd_low", + "vpd_high", + "vpd_reverse", + "expected_payload", + ), + [ + ( + True, + -20, + 80, + True, + False, + 1, + 99, + False, + False, + 0.00, + 99.99, + False, + False, + -60.0, + 60.0, + False, + False, + 0.00, + 10.00, + False, + "04d063001401bc003c0000a00063630000", + ), + ( + True, + -20, + 59.5, + True, + False, + 1, + 99, + False, + False, + 0.00, + 99.99, + False, + False, + -60.0, + 60.0, + False, + False, + 0.00, + 10.00, + False, + "04bb63501401bc003c0000a00063630000", + ), + ( + True, + -11.5, + 59.5, + True, + False, + 1, + 99, + False, + False, + 0.00, + 99.99, + False, + False, + -60.0, + 60.0, + False, + False, + 0.00, + 10.00, + False, + "04bb63550b01bc003c0000a00063630000", + ), + ( + True, + -11.5, + 59.5, + False, + False, + 1, + 99, + False, + False, + 0.00, + 99.99, + False, + False, + -60.0, + 60.0, + False, + False, + 0.00, + 10.00, + False, + "03bb63550b01bc003c0000a00063630000", + ), + ( + True, + -11.5, + 59.5, + False, + True, + 20, + 80, + False, + False, + 0.00, + 99.99, + False, + False, + -60.0, + 60.0, + False, + False, + 0.00, + 10.00, + False, + "33bb50550b14bc003c0000a00063630000", + ), + ( + True, + -11.5, + 59.5, + False, + True, + 20, + 80, + True, + False, + 0.00, + 99.99, + False, + False, + -60.0, + 60.0, + False, + False, + 0.00, + 10.00, + False, + "43bb50550b14bc003c0000a00063630000", + ), + ( + False, + -11.5, + 59.5, + False, + True, + 20, + 80, + True, + False, + 0.00, + 99.99, + False, + False, + -60.0, + 60.0, + False, + False, + 0.00, + 10.00, + False, + "40bb50550b14bc003c0000a00063630000", + ), + ( + False, + -11.5, + 59.5, + False, + False, + 20, + 80, + False, + True, + 15.00, + 70.00, + False, + False, + -60.0, + 60.0, + False, + False, + 0.00, + 10.00, + False, + "00bb50550b14bc003c0000a00146000f00", + ), + ( + False, + -11.5, + 59.5, + False, + False, + 20, + 80, + False, + True, + 17.50, + 69.50, + True, + False, + -60.0, + 60.0, + False, + False, + 0.00, + 10.00, + False, + "00bb50550b14bc003c0000a00245321132", + ), + ( + False, + -11.5, + 59.5, + False, + False, + 20, + 80, + False, + False, + 06.00, + 99.99, + False, + True, + -47.0, + 41.0, + False, + False, + 0.00, + 10.00, + False, + "00bb50550b14a9002f0000a00c63630600", + ), + ( + False, + -11.5, + 59.5, + False, + False, + 20, + 80, + False, + False, + 06.00, + 99.99, + False, + True, + -47.0, + 41.0, + True, + False, + 0.00, + 10.00, + False, + "00bb50550b14a9002f0000a01063630600", + ), + ( + False, + -11.5, + 59.5, + False, + False, + 20, + 80, + False, + False, + 06.00, + 99.99, + False, + True, + -15.5, + 42.5, + True, + False, + 0.00, + 10.00, + False, + "00bb50550b14aa550f0000a01063630600", + ), + ( + False, + -11.5, + 59.5, + False, + False, + 20, + 80, + False, + False, + 06.00, + 99.99, + False, + False, + -15.5, + 42.5, + True, + True, + 0.00, + 10.00, + False, + "00bb50550b14aa550f0000a06063630600", + ), + ( + False, + -11.5, + 59.5, + False, + False, + 20, + 80, + False, + False, + 06.00, + 99.99, + False, + False, + -15.5, + 42.5, + True, + True, + 1.05, + 8.75, + True, + "00bb50550b14aa550f4b05818063630600", + ), + ], +) +async def test_set_alert_temperature_humidity( + temperature_alert: bool, + temperature_low: float, + temperature_high: float, + temperature_reverse: bool, + humidity_alert: bool, + humidity_low: int, + humidity_high: int, + humidity_reverse: bool, + absolute_humidity_alert: bool, + absolute_humidity_low: float, + absolute_humidity_high: float, + absolute_humidity_reverse: bool, + dewpoint_alert: bool, + dewpoint_low: float, + dewpoint_high: float, + dewpoint_reverse: bool, + vpd_alert: bool, + vpd_low: float, + vpd_high: float, + vpd_reverse: bool, + expected_payload: str, +): + # Values based on actual measurements from the app + + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_alert_temperature_humidity( + temperature_alert, + temperature_low, + temperature_high, + temperature_reverse, + humidity_alert, + humidity_low, + humidity_high, + humidity_reverse, + absolute_humidity_alert, + absolute_humidity_low, + absolute_humidity_high, + absolute_humidity_reverse, + dewpoint_alert, + dewpoint_low, + dewpoint_high, + dewpoint_reverse, + vpd_alert, + vpd_low, + vpd_high, + vpd_reverse, + ) + device._send_command.assert_called_with("570f44" + expected_payload) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("on", "co2_low", "co2_high", "reverse", "expected_payload"), + [ + (False, 1000, 2000, False, "0007d003e8"), + (True, 1000, 2000, False, "0307d003e8"), + (True, 700, 2000, False, "0307d002bc"), + (True, 700, 1500, False, "0305dc02bc"), + (True, 700, 1500, True, "0405dc02bc"), + ], +) +async def test_set_alert_co2( + on: bool, co2_low: int, co2_high: int, reverse: bool, expected_payload: str +): + # Values based on actual measurements from the app + + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_alert_co2(on, co2_low, co2_high, reverse) + device._send_command.assert_called_with("570f68020301" + expected_payload) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("minutes", "expected_payload"), + [ + (5, "012c"), + (10, "0258"), + (30, "0708"), + ], +) +async def test_set_temperature_update_interval(minutes: int, expected_payload: str): + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_temperature_update_interval(minutes) + device._send_command.assert_called_with("570f68070105" + expected_payload) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("minutes", "expected_payload"), + [ + (5, "012c"), + (10, "0258"), + (30, "0708"), + ], +) +async def test_set_co2_update_interval(minutes: int, expected_payload: str): + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_co2_update_interval(minutes) + device._send_command.assert_called_with("570f680b06" + expected_payload) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("change_unit", "change_data_source", "expected_payload"), + [ + (True, True, "0001"), + (True, False, "0000"), + (False, True, "0101"), + (False, False, "0100"), + ], +) +async def test_set_button_function( + change_unit: bool, change_data_source: bool, expected_payload: str +): + # Values based on actual measurements from the app + + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_button_function(change_unit, change_data_source) + device._send_command.assert_called_with("570f68070106" + expected_payload) + + +@pytest.mark.asyncio +async def test_force_new_co2_measurement(): + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.force_new_co2_measurement() + device._send_command.assert_called_with("570f680b04") + + +@pytest.mark.asyncio +async def test_calibrate_co2_sensor(): + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.calibrate_co2_sensor() + device._send_command.assert_called_with("570f680b02") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("sound_on", "volume", "expected_payload"), + [ + (False, 4, "0401"), + (True, 2, "0202"), + (True, 3, "0302"), + (True, 4, "0402"), + ], +) +async def test_set_alert_sound(sound_on: bool, volume: int, expected_payload: str): + # Values based on actual measurements from the app + + device = create_device() + device._send_command.return_value = bytes.fromhex("01") + + await device.set_alert_sound(sound_on, volume) + device._send_command.assert_called_with("570f680204" + expected_payload)