From 8295af5fdd096c81f61576bc491093c6f91fbf99 Mon Sep 17 00:00:00 2001 From: David Simon Date: Mon, 4 Sep 2023 14:35:00 -0400 Subject: [PATCH 1/3] Add Number entity, update README docs Signed-off-by: David Simon --- README.md | 93 ++++++++++++++++++++++++++++++--- ha_mqtt_discoverable/sensors.py | 49 +++++++++++++++++ tests/test_number.py | 35 +++++++++++++ 3 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 tests/test_number.py diff --git a/README.md b/README.md index c8806d1..1a08817 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,14 @@ settings = Settings(mqtt=mqtt_settings, entity=switch_info) # To receive state commands from HA, define a callback function: def my_callback(client: Client, user_data, message: MQTTMessage): payload = message.payload.decode() - logging.info(f"Received {payload} from HA") - # Your custom code... + if payload == "ON": + turn_my_custom_thing_on() + # Let HA know that the switch was successfully activated + my_switch.on() + elif payload == "OFF": + turn_my_custom_thing_off() + # Let HA know that the switch was successfully deactivated + my_switch.off() # Define an optional object to be passed back to the callback user_data = "Some custom data" @@ -115,12 +121,46 @@ user_data = "Some custom data" # Instantiate the switch my_switch = Switch(settings, my_callback, user_data) -# Change the state of the sensor, publishing an MQTT message that gets picked up by HA -my_switch.on() +# Set the initial state of the switch, which also makes it discoverable my_switch.off() ``` + +### Button + +The button publishes no state, it simply receives a command from HA. + +You must call `write_config` on a Button after creating it to make it discoverable. + +```py +from ha_mqtt_discoverable import Settings +from ha_mqtt_discoverable.sensors import Button, ButtonInfo +from paho.mqtt.client import Client, MQTTMessage + +# Configure the required parameters for the MQTT broker +mqtt_settings = Settings.MQTT(host="localhost") + +# Information about the button +button_info = ButtonInfo(name="test") + +settings = Settings(mqtt=mqtt_settings, entity=button_info) + +# To receive button commands from HA, define a callback function: +def my_callback(client: Client, user_data, message: MQTTMessage): + perform_my_custom_action() + +# Define an optional object to be passed back to the callback +user_data = "Some custom data" + +# Instantiate the button +my_button = Button(settings, my_callback, user_data) + +# Publish the button's discoverability message to let HA automatically notice it +my_button.write_config() + +``` + ### Text The text is an `helper entity`, showing an input field in the HA UI that the user can interact with. @@ -145,7 +185,9 @@ settings = Settings(mqtt=mqtt_settings, entity=switch_info) def my_callback(client: Client, user_data, message: MQTTMessage): text = message.payload.decode() logging.info(f"Received {text} from HA") - # Your custom code... + do_some_custom_thing(text) + # Send an MQTT message to confirm to HA that the text was changed + my_text.set_text(text) # Define an optional object to be passed back to the callback user_data = "Some custom data" @@ -153,11 +195,50 @@ user_data = "Some custom data" # Instantiate the text my_text = Text(settings, my_callback, user_data) -# Change the text displayed in HA UI, publishing an MQTT message that gets picked up by HA +# Set the initial text displayed in HA UI, publishing an MQTT message that gets picked up by HA my_text.set_text("Some awesome text") ``` +### Number + +The number entity is similar to the text entity, but for a numeric value instead of a string. +It is possible to act upon receiving changes in HA by defining a `callback` function, as the following example shows: + +#### Usage + +```py +from ha_mqtt_discoverable import Settings +from ha_mqtt_discoverable.sensors import Number, NumberInfo +from paho.mqtt.client import Client, MQTTMessage + +# Configure the required parameters for the MQTT broker +mqtt_settings = Settings.MQTT(host="localhost") + +# Information about the `number` entity. +number_info = NumberInfo(name="test", min=0, max=50, mode="slider", step=5) + +settings = Settings(mqtt=mqtt_settings, entity=switch_info) + +# To receive number updates from HA, define a callback function: +def my_callback(client: Client, user_data, message: MQTTMessage): + number = int(message.payload.decode()) + logging.info(f"Received {number} from HA") + do_some_custom_thing(number) + # Send an MQTT message to confirm to HA that the number was changed + my_text.set_value(number) + +# Define an optional object to be passed back to the callback +user_data = "Some custom data" + +# Instantiate the number +my_number = Number(settings, my_callback, user_data) + +# Set the initial number displayed in HA UI, publishing an MQTT message that gets picked up by HA +my_number.set_value(42.0) + +``` + ## Device From the [Home Assistant documentation](https://developers.home-assistant.io/docs/device_registry_index): > A device is a special entity in Home Assistant that is represented by one or more entities. diff --git a/ha_mqtt_discoverable/sensors.py b/ha_mqtt_discoverable/sensors.py index 982837e..ba20cfa 100644 --- a/ha_mqtt_discoverable/sensors.py +++ b/ha_mqtt_discoverable/sensors.py @@ -99,6 +99,34 @@ class TextInfo(EntityInfo): """If the published message should have the retain flag on or not""" +class NumberInfo(EntityInfo): + """Information about the 'number' entity""" + + component: str = "number" + + max: float | int = 100 + """The maximum value of the number (defaults to 100)""" + min: float | int = 1 + """The maximum value of the number (defaults to 1)""" + mode: Optional[str] = None + """Control how the number should be displayed in the UI. Can be set to box + or slider to force a display mode.""" + optimistic: Optional[bool] = None + """Flag that defines if switch works in optimistic mode. + Default: true if no state_topic defined, else false.""" + payload_reset: Optional[str] = None + """A special payload that resets the state to None when received on the state_topic.""" + retain: Optional[bool] = None + """If the published message should have the retain flag on or not""" + state_topic: Optional[str] = None + """The MQTT topic subscribed to receive state updates.""" + step: Optional[float] = None + """Step value. Smallest acceptable value is 0.001. Defaults to 1.0.""" + unit_of_measurement: Optional[str] = None + """Defines the unit of measurement of the sensor, if any. The + unit_of_measurement can be null.""" + + class DeviceTriggerInfo(EntityInfo): """Information about the device trigger""" @@ -229,3 +257,24 @@ def set_text(self, text: str) -> None: logger.info(f"Setting {self._entity.name} to {text} using {self.state_topic}") self._state_helper(str(text)) + + +class Number(Subscriber[NumberInfo]): + """Implements an MQTT number: + https://www.home-assistant.io/integrations/number.mqtt/ + """ + + def set_value(self, value: float) -> None: + """ + Update the numeric value. Raises an error if it is not within the acceptable range. + + Args: + text(str): Value of the text configured for this entity + """ + if not self._entity.min <= value <= self._entity.max: + raise RuntimeError( + f"Value is not within configured boundaries [{self._entity.min}, {self._entity.max}]" + ) + + logger.info(f"Setting {self._entity.name} to {value} using {self.state_topic}") + self._state_helper(value) diff --git a/tests/test_number.py b/tests/test_number.py new file mode 100644 index 0000000..053c242 --- /dev/null +++ b/tests/test_number.py @@ -0,0 +1,35 @@ +import pytest +from ha_mqtt_discoverable import Settings +from ha_mqtt_discoverable.sensors import Number, NumberInfo + + +@pytest.fixture() +def number() -> Number: + mqtt_settings = Settings.MQTT(host="localhost") + number_info = NumberInfo(name="test", min=5.0, max=90.0) + settings = Settings(mqtt=mqtt_settings, entity=number_info) + # Define empty callback + return Number(settings, lambda *_: None) + + +def test_required_config(): + mqtt_settings = Settings.MQTT(host="localhost") + number_info = NumberInfo(name="test") + settings = Settings(mqtt=mqtt_settings, entity=number_info) + # Define empty callback + number = Number(settings, lambda *_: None) + assert number is not None + + +def test_set_value(number: Number): + number.set_value(42.0) + + +def test_number_too_small(number: Number): + with pytest.raises(RuntimeError): + number.set_value(4.0) + + +def test_number_too_large(number: Number): + with pytest.raises(RuntimeError): + number.set_value(91.0) From a82be6781b51df7379783855e4a88319a33f241c Mon Sep 17 00:00:00 2001 From: David Simon Date: Tue, 5 Sep 2023 20:02:24 -0400 Subject: [PATCH 2/3] Fix variable names in README examples Signed-off-by: David Simon --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1a08817..717f869 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ mqtt_settings = Settings.MQTT(host="localhost") # Information about the `text` entity text_info = TextInfo(name="test") -settings = Settings(mqtt=mqtt_settings, entity=switch_info) +settings = Settings(mqtt=mqtt_settings, entity=text_info) # To receive text updates from HA, define a callback function: def my_callback(client: Client, user_data, message: MQTTMessage): @@ -218,7 +218,7 @@ mqtt_settings = Settings.MQTT(host="localhost") # Information about the `number` entity. number_info = NumberInfo(name="test", min=0, max=50, mode="slider", step=5) -settings = Settings(mqtt=mqtt_settings, entity=switch_info) +settings = Settings(mqtt=mqtt_settings, entity=number_info) # To receive number updates from HA, define a callback function: def my_callback(client: Client, user_data, message: MQTTMessage): @@ -263,7 +263,7 @@ device_info = DeviceInfo(name="My device", identifiers="device_id") # `unique_id` must also be set, otherwise Home Assistant will not display the device in the UI motion_sensor_info = BinarySensorInfo(name="My motion sensor", device_class="motion", unique_id="my_motion_sensor", device=device_info) -motion_settings = Settings(mqtt=mqtt_settings, entity=sensor_info) +motion_settings = Settings(mqtt=mqtt_settings, entity=motion_sensor_info) # Instantiate the sensor motion_sensor = BinarySensor(motion_settings) @@ -302,7 +302,7 @@ device_info = DeviceInfo(name="My device", identifiers="device_id") # Associate the sensor with the device via the `device` parameter trigger_into = DeviceTriggerInfo(name="MyTrigger", type="button_press", subtype="button_1", unique_id="my_device_trigger", device=device_info) -settings = Settings(mqtt=mqtt_settings, entity=sensor_info) +settings = Settings(mqtt=mqtt_settings, entity=trigger_info) # Instantiate the device trigger mytrigger = DeviceTrigger(settings) From 58dcbf62222eb72f03877983eafa949b32a14f0e Mon Sep 17 00:00:00 2001 From: David Simon Date: Sat, 16 Sep 2023 20:36:53 -0400 Subject: [PATCH 3/3] Lint fixes Signed-off-by: David Simon --- ha_mqtt_discoverable/sensors.py | 35 +++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/ha_mqtt_discoverable/sensors.py b/ha_mqtt_discoverable/sensors.py index ba20cfa..a2d42fb 100644 --- a/ha_mqtt_discoverable/sensors.py +++ b/ha_mqtt_discoverable/sensors.py @@ -33,8 +33,9 @@ class BinarySensorInfo(EntityInfo): component: str = "binary_sensor" off_delay: Optional[int] = None - """For sensors that only send on state updates (like PIRs), - this variable sets a delay in seconds after which the sensor’s state will be updated back to off.""" + """For sensors that only send on state updates (like PIRs), this variable + sets a delay in seconds after which the sensor's state will be updated back + to off.""" payload_off: str = "off" """Payload to send for the ON state""" payload_on: str = "on" @@ -57,13 +58,13 @@ class SwitchInfo(EntityInfo): """Flag that defines if switch works in optimistic mode. Default: true if no state_topic defined, else false.""" payload_off: str = "OFF" - """The payload that represents off state. If specified, will be used for both comparing - to the value in the state_topic (see value_template and state_off for details) - and sending as off command to the command_topic""" + """The payload that represents off state. If specified, will be used for + both comparing to the value in the state_topic (see value_template and + state_off for details) and sending as off command to the command_topic""" payload_on: str = "ON" - """The payload that represents on state. If specified, will be used for both comparing - to the value in the state_topic (see value_template and state_on for details) - and sending as on command to the command_topic.""" + """The payload that represents on state. If specified, will be used for both + comparing to the value in the state_topic (see value_template and state_on + for details) and sending as on command to the command_topic.""" retain: Optional[bool] = None """If the published message should have the retain flag on or not""" state_topic: Optional[str] = None @@ -115,7 +116,8 @@ class NumberInfo(EntityInfo): """Flag that defines if switch works in optimistic mode. Default: true if no state_topic defined, else false.""" payload_reset: Optional[str] = None - """A special payload that resets the state to None when received on the state_topic.""" + """A special payload that resets the state to None when received on the + state_topic.""" retain: Optional[bool] = None """If the published message should have the retain flag on or not""" state_topic: Optional[str] = None @@ -186,7 +188,8 @@ def set_state(self, state: str | int | float) -> None: self._state_helper(str(state)) -# Inherit the on and off methods from the BinarySensor class, changing only the documentation string +# Inherit the on and off methods from the BinarySensor class, changing only the +# documentation string class Switch(Subscriber[SwitchInfo], BinarySensor): """Implements an MQTT switch: https://www.home-assistant.io/integrations/switch.mqtt @@ -217,8 +220,8 @@ class DeviceTrigger(Discoverable[DeviceTriggerInfo]): """ def generate_config(self) -> dict[str, Any]: - """Publish a custom configuration: - since this entity does not provide a `state_topic`, HA expects a `topic` key in the config + """Publish a custom configuration: since this entity does not provide a + `state_topic`, HA expects a `topic` key in the config """ config = super().generate_config() # Publish our `state_topic` as `topic` @@ -251,8 +254,9 @@ def set_text(self, text: str) -> None: text(str): Value of the text configured for this entity """ if not self._entity.min <= len(text) <= self._entity.max: + bound = f"[{self._entity.min}, {self._entity.max}]" raise RuntimeError( - f"Text is not within configured length boundaries [{self._entity.min}, {self._entity.max}]" + f"Text is not within configured length boundaries {bound}" ) logger.info(f"Setting {self._entity.name} to {text} using {self.state_topic}") @@ -266,14 +270,15 @@ class Number(Subscriber[NumberInfo]): def set_value(self, value: float) -> None: """ - Update the numeric value. Raises an error if it is not within the acceptable range. + Update the numeric value. Raises an error if not within the acceptable range. Args: text(str): Value of the text configured for this entity """ if not self._entity.min <= value <= self._entity.max: + bound = f"[{self._entity.min}, {self._entity.max}]" raise RuntimeError( - f"Value is not within configured boundaries [{self._entity.min}, {self._entity.max}]" + f"Value is not within configured boundaries {bound}" ) logger.info(f"Setting {self._entity.name} to {value} using {self.state_topic}")