Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Number entity type, and update README docs for Switch and Button #115

Merged
merged 3 commits into from
Sep 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 90 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,21 +106,61 @@ 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"

# 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.
Expand All @@ -139,25 +179,66 @@ 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):
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"

# 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=number_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.
Expand All @@ -182,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)
Expand Down Expand Up @@ -221,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)
Expand Down
78 changes: 66 additions & 12 deletions ha_mqtt_discoverable/sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -99,6 +100,35 @@ 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"""

Expand Down Expand Up @@ -158,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
Expand Down Expand Up @@ -189,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`
Expand Down Expand Up @@ -223,9 +254,32 @@ 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}")
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 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 {bound}"
)

logger.info(f"Setting {self._entity.name} to {value} using {self.state_topic}")
self._state_helper(value)
35 changes: 35 additions & 0 deletions tests/test_number.py
Original file line number Diff line number Diff line change
@@ -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)