Skip to content
Draft
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
17 changes: 12 additions & 5 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,32 @@
**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*

- [Description](#description)
- [Credit](#credit)
- [License Acceptance](#license-acceptance)
- [Type of changes](#type-of-changes)
- [Checklist](#checklist)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->

<!--- Provide a general summary of your changes in the Title above -->
<!-- Provide a general summary of your changes in the Title above -->

# Description

<!--- Describe your changes in detail -->
<!-- Describe your changes in detail -->

# Credit
<!-- Releases are announced on Mastodon. In order for me to credit people properly, -->
<!-- if you have a mastodon account, please put it here to have it mentioned in the -->
<!-- release announcement. If there's another way you want to be credited, please -->
<!-- add details here. -->

# License Acceptance

- [ ] This repository is Apache version 2.0 licensed and by making this PR, I am contributing my changes to the repository under the terms of the Apache 2 license.

# Type of changes

<!--- What types of changes does your submission introduce? Put an `x` in all the boxes that apply: [x] -->
<!-- What types of changes does your submission introduce? Put an `x` in all the boxes that apply: [x] -->

- [ ] Add/update a helper script
- [ ] Add/update link to an external resource like a blog post or video
Expand All @@ -33,8 +40,8 @@

# Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. [x] -->
<!--- If you're unsure about any of these, don't hesitate to ask. I'm happy to help! -->
<!-- Go over all the following points, and put an `x` in all the boxes that apply. [x] -->
<!-- If you're unsure about any of these, don't hesitate to ask. I'm happy to help! -->

- [ ] I have read the [CONTRIBUTING](https://github.com/unixorn/ha-mqtt-discovery/blob/main/Contributing.md) document.
- [ ] All new and existing tests pass.
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ repos:
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.2
rev: v0.12.7
hooks:
# Run the linter.
- id: ruff
Expand Down
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Using MQTT discoverable devices lets us add new sensors and devices to HA withou
- [Binary sensor](#binary-sensor)
- [Button](#button)
- [Camera](#camera)
- [Climate](#climate)
- [Covers](#covers)
- [Device](#device)
- [Device trigger](#device-trigger)
Expand Down Expand Up @@ -55,6 +56,7 @@ The following Home Assistant entities are currently implemented:
- Button
- Camera
- Cover
- Climate
- Device
- Device trigger
- Image
Expand Down Expand Up @@ -164,6 +166,61 @@ my_camera = Camera(settings, my_callback, user_data)
my_camera.set_topic("zanzito/shared_locations/my-device") # not needed if already defined
```

### Climate

The following example creates a climate entity with temperature control and mode selection:

```py
from ha_mqtt_discoverable import Settings
from ha_mqtt_discoverable.sensors import Climate, ClimateInfo
from paho.mqtt.client import Client, MQTTMessage

# Configure the required parameters for the MQTT broker
mqtt_settings = Settings.MQTT(host="localhost")

# Information about the climate entity
climate_info = ClimateInfo(
name="MyClimate",
temperature_unit="C",
min_temp=16,
max_temp=32,
modes=["off", "heat"]
)

settings = Settings(mqtt=mqtt_settings, entity=climate_info)

# To receive commands from HA, define a callback function:
def my_callback(client: Client, user_data, message: MQTTMessage):
# Make sure received payload is JSON
try:
payload = json.loads(message.payload.decode())
except ValueError:
print("Ony JSON schema is supported for climate entities!")
return

if payload['command'] == "mode":
set_my_custom_climate_mode(payload['value'])
elif payload['command'] == "temperature":
set_my_custom_climate_temperature(payload['value'])
else:
print("Unknown command")

# Define an optional object to be passed back to the callback
user_data = "Some custom data"

# Instantiate the climate entity
my_climate = Climate(settings, my_callback, user_data)

# Set the current temperature
my_climate.set_current_temperature(24.5)

# Set the target temperature
my_climate.set_target_temperature(25.0)

# Change the HVAC mode
my_climate.set_mode("heat")
```

### Covers

A cover has five possible states `open`, `closed`, `opening`, `closing` and `stopped`. Most other entities use the states as command payload, but covers differentiate on this. The HA user can either open, close or stop it in the covers current position.
Expand Down
77 changes: 76 additions & 1 deletion ha_mqtt_discoverable/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -849,7 +849,7 @@ def __del__(self):

class Subscriber(Discoverable[EntityType]):
"""
Specialized sub-lass that listens to commands coming from an MQTT topic
Specialized subclass that listens to commands coming from an MQTT topic
"""

T = TypeVar("T") # Used in the callback function
Expand Down Expand Up @@ -898,3 +898,78 @@ def generate_config(self) -> dict[str, Any]:
"command_topic": self._command_topic,
}
return config | topics


class ClimateSubscriber(Discoverable[EntityType]):
"""
Specialized subclass that listens to commands coming from an MQTT topic
"""

T = TypeVar("T") # Used in the callback function

def __init__(
self,
settings: Settings[EntityType],
command_callback: Callable[[mqtt.Client, T, mqtt.MQTTMessage], Any],
user_data: T = None,
) -> None:
"""
Entity that listens to commands from an MQTT topic.

Args:
settings: Settings for the entity we want to create in Home Assistant.
See the `Settings` class for the available options.
command_callback: Callback function invoked when there is a command
coming from the MQTT command topic
"""

# Callback invoked when the MQTT connection is established
def on_client_connected(client: mqtt.Client, *args):
# Publish this button in Home Assistant
# Subscribe to the command topic
result_mode, _ = client.subscribe(self._mode_command_topic, qos=1)
result_temperature, _ = client.subscribe(self._temperature_command_topic, qos=1)

if result_mode is not mqtt.MQTT_ERR_SUCCESS:
raise RuntimeError("Error subscribing to MQTT mode command topic")

# Invoke the parent init
super().__init__(settings, on_client_connected)
# Define the command topic to receive commands from HA, using `hmd` topic prefix
self._mode_command_topic = f"{self._settings.mqtt.state_prefix}/{self._entity_topic}/mode_command"
self._mode_state_topic = f"{self._settings.mqtt.state_prefix}/{self._entity_topic}/mode_state"
self._current_temperature_topic = f"{self._settings.mqtt.state_prefix}/{self._entity_topic}/current_temperature"
self._temperature_command_topic = f"{self._settings.mqtt.state_prefix}/{self._entity_topic}/temperature_command"
self._temperature_state_topic = f"{self._settings.mqtt.state_prefix}/{self._entity_topic}/temperature_state"

# Register the user-supplied callback function with its user_data
self.mqtt_client.user_data_set(user_data)
self.mqtt_client.on_message = command_callback

# Manually connect the MQTT client
self._connect_client()

def generate_config(self) -> dict[str, Any]:
"""Override base config to add the command topic of this switch"""
config = super().generate_config()
# Add the MQTT command topic to the existing config object
topics = {
"mode_command_topic": self._mode_command_topic,
"mode_command_template": json.dumps(
{
"command": "mode",
"value": "{{ value }}",
}
),
"temperature_command_topic": self._temperature_command_topic,
"temperature_command_template": json.dumps(
{
"command": "temperature",
"value": "{{ value }}",
}
),
"mode_state_topic": self._mode_state_topic,
"current_temperature_topic": self._current_temperature_topic,
"temperature_state_topic": self._temperature_state_topic,
}
return config | topics
41 changes: 41 additions & 0 deletions ha_mqtt_discoverable/sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from pydantic import Field, model_validator

from ha_mqtt_discoverable import (
ClimateSubscriber,
DeviceInfo,
Discoverable,
EntityInfo,
Expand All @@ -46,6 +47,18 @@ class BinarySensorInfo(EntityInfo):
"""Payload to send for the OFF state"""


class ClimateInfo(EntityInfo):
"""Climate specific information"""

component: str = "climate"
optimistic: bool | None = None
temperature_unit: Literal["C", "F"] = "C"
min_temp: float = 7.0
max_temp: float = 35.0
modes: list[str] = ["off", "heat"] # "cool", "auto", "dry", "fan_only"
retain: bool | None = True


class SensorInfo(EntityInfo):
"""Sensor specific information"""

Expand Down Expand Up @@ -370,6 +383,34 @@ def update_state(self, state: bool) -> None:
self._state_helper(state=state_message)


class Climate(ClimateSubscriber[ClimateInfo]):
"""Implements an MQTT climate device:
https://www.home-assistant.io/integrations/climate.mqtt/
"""

def set_current_temperature(self, temperature: float) -> None:
"""Set target temperature"""
if temperature < self._entity.min_temp or temperature > self._entity.max_temp:
raise RuntimeError(
f"Temperature {temperature} is outside valid range [{self._entity.min_temp}, {self._entity.max_temp}]"
)
self._state_helper(temperature, self._current_temperature_topic)

def set_target_temperature(self, temperature: float) -> None:
"""Set target temperature"""
if temperature < self._entity.min_temp or temperature > self._entity.max_temp:
raise RuntimeError(
f"Temperature {temperature} is outside valid range [{self._entity.min_temp}, {self._entity.max_temp}]"
)
self._state_helper(temperature, self._temperature_state_topic)

def set_mode(self, mode: str) -> None:
"""Set HVAC mode"""
if mode not in self._entity.modes:
raise RuntimeError(f"Mode {mode} is not in supported modes: {self._entity.modes}")
self._state_helper(mode, self._mode_state_topic)


class Sensor(Discoverable[SensorInfo]):
def set_state(self, state: str | int | float, last_reset: str = None) -> None:
"""
Expand Down
46 changes: 23 additions & 23 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ authors = [{name = "Joe Block", email = "[email protected]"}]
readme = "README.md"
requires-python = ">=3.10.0,<4.0"
dependencies = [
"pyaml==25.5.0",
"pyaml==25.7.0",
"paho-mqtt==2.1.0",
"gitlike-commands (>=0.2.1,<0.4.0)",
"pydantic==2.11.7",
Expand All @@ -20,7 +20,7 @@ packages = [{include = "ha_mqtt_discoverable"}]
[tool.poetry.group.dev.dependencies]
pytest = "^8.4.1"
pre-commit = "^4.2.0"
ruff = "^0.12.2"
ruff = "^0.12.7"


[tool.poetry.group.test.dependencies]
Expand Down
Loading