created python heater to contron the hln heater

This commit is contained in:
2026-02-04 18:52:02 -05:00
parent 557c1a4d5d
commit 80af3377e6
9 changed files with 268 additions and 1 deletions

View File

@@ -23,9 +23,11 @@
apscheduler
fastapi
fastapi-cli
httpx
mypy
polars
psycopg
pydantic
pyfakefs
pytest
pytest-cov
@@ -37,6 +39,7 @@
sqlalchemy
sqlalchemy
textual
tinytuya
typer
types-requests
]

View File

@@ -7,7 +7,7 @@ requires-python = "~=3.13.0"
readme = "README.md"
license = "MIT"
# these dependencies are a best effort and aren't guaranteed to work
dependencies = ["apprise", "apscheduler", "polars", "requests", "typer"]
dependencies = ["apprise", "apscheduler", "httpx", "polars", "pydantic", "pyyaml", "requests", "typer"]
[dependency-groups]
dev = [
@@ -55,6 +55,9 @@ lint.ignore = [
"python/orm/**" = [
"TC003", # (perm) this creates issues because sqlalchemy uses these at runtime
]
"python/congress_tracker/**" = [
"TC003", # (perm) this creates issues because sqlalchemy uses these at runtime
]
"python/alembic/**" = [
"INP001", # (perm) this creates LSP issues for alembic
]

View File

@@ -0,0 +1 @@
"""Tuya heater control service."""

View File

@@ -0,0 +1,68 @@
"""TinyTuya device controller for heater."""
import logging
import tinytuya
from python.heater.models import ActionResult, DeviceConfig, HeaterStatus
logger = logging.getLogger(__name__)
# DPS mapping for heater
DPS_POWER = "1" # bool: on/off
DPS_SETPOINT = "101" # int: target temp (read-only)
DPS_STATE = "102" # str: "Stop", "Heat", etc.
DPS_UNKNOWN = "104" # int: unknown
DPS_ERROR = "108" # int: last error code
class HeaterController:
"""Controls a Tuya heater device via local network."""
def __init__(self, config: DeviceConfig) -> None:
self.device = tinytuya.Device(config.device_id, config.ip, config.local_key)
self.device.set_version(config.version)
self.device.set_socketTimeout(0.5)
self.device.set_socketRetryLimit(1)
def status(self) -> HeaterStatus:
"""Get current heater status."""
data = self.device.status()
if "Error" in data:
logger.error("Device error: %s", data)
return HeaterStatus(power=False, raw_dps={"error": data["Error"]})
dps = data.get("dps", {})
return HeaterStatus(
power=bool(dps.get(DPS_POWER, False)),
setpoint=dps.get(DPS_SETPOINT),
state=dps.get(DPS_STATE),
error_code=dps.get(DPS_ERROR),
raw_dps=dps,
)
def turn_on(self) -> ActionResult:
"""Turn heater on."""
try:
self.device.set_value(DPS_POWER, True)
return ActionResult(success=True, action="on", power=True)
except Exception as error:
logger.exception("Failed to turn on")
return ActionResult(success=False, action="on", error=str(error))
def turn_off(self) -> ActionResult:
"""Turn heater off."""
try:
self.device.set_value(DPS_POWER, False)
return ActionResult(success=True, action="off", power=False)
except Exception as error:
logger.exception("Failed to turn off")
return ActionResult(success=False, action="off", error=str(error))
def toggle(self) -> ActionResult:
"""Toggle heater power state."""
status = self.status()
if status.power:
return self.turn_off()
return self.turn_on()

85
python/heater/main.py Normal file
View File

@@ -0,0 +1,85 @@
"""FastAPI heater control service."""
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated
import typer
import uvicorn
from fastapi import FastAPI, HTTPException
from python.common import configure_logger
from python.heater.controller import HeaterController
from python.heater.models import ActionResult, DeviceConfig, HeaterStatus
logger = logging.getLogger(__name__)
def create_app(config: DeviceConfig) -> FastAPI:
"""Create FastAPI application."""
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.controller = HeaterController(config)
yield
app = FastAPI(
title="Heater Control API",
description="Fast local control for Tuya heater",
lifespan=lifespan,
)
@app.get("/status")
def get_status() -> HeaterStatus:
return app.state.controller.status()
@app.post("/on")
def heater_on() -> ActionResult:
result = app.state.controller.turn_on()
if not result.success:
raise HTTPException(status_code=500, detail=result.error)
return result
@app.post("/off")
def heater_off() -> ActionResult:
result = app.state.controller.turn_off()
if not result.success:
raise HTTPException(status_code=500, detail=result.error)
return result
@app.post("/toggle")
def heater_toggle() -> ActionResult:
result = app.state.controller.toggle()
if not result.success:
raise HTTPException(status_code=500, detail=result.error)
return result
return app
def serve(
host: Annotated[str, typer.Option("--host", "-h", help="Host to bind to")],
port: Annotated[int, typer.Option("--port", "-p", help="Port to bind to")] = 8124,
log_level: Annotated[str, typer.Option("--log-level", "-l", help="Log level")] = "INFO",
device_id: Annotated[str | None, typer.Option("--device-id", envvar="TUYA_DEVICE_ID")] = None,
device_ip: Annotated[str | None, typer.Option("--device-ip", envvar="TUYA_DEVICE_IP")] = None,
local_key: Annotated[str | None, typer.Option("--local-key", envvar="TUYA_LOCAL_KEY")] = None,
) -> None:
"""Start the heater control API server."""
configure_logger(log_level)
logger.info("Starting heater control API server")
if not device_id or not device_ip or not local_key:
error = "Must provide device ID, IP, and local key"
raise typer.Exit(error)
config = DeviceConfig(device_id=device_id, ip=device_ip, local_key=local_key)
app = create_app(config)
uvicorn.run(app, host=host, port=port)
if __name__ == "__main__":
typer.run(serve)

31
python/heater/models.py Normal file
View File

@@ -0,0 +1,31 @@
"""Pydantic models for heater API."""
from pydantic import BaseModel, Field
class DeviceConfig(BaseModel):
"""Tuya device configuration."""
device_id: str
ip: str
local_key: str
version: float = 3.5
class HeaterStatus(BaseModel):
"""Current heater status."""
power: bool
setpoint: int | None = None
state: str | None = None # "Stop", "Heat", etc.
error_code: int | None = None
raw_dps: dict[str, object] = Field(default_factory=dict)
class ActionResult(BaseModel):
"""Result of a heater action."""
success: bool
action: str
power: bool | None = None
error: str | None = None

View File

@@ -0,0 +1,33 @@
{
pkgs,
inputs,
...
}:
{
networking.firewall.allowedTCPPorts = [ 8124 ];
systemd.services.heater-api = {
description = "Tuya Heater Control API";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
PYTHONPATH = "${inputs.self}/";
};
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.my_python}/bin/python -m python.heater.main --host 0.0.0.0 --port 8124";
EnvironmentFile = "/etc/heater.env";
Restart = "on-failure";
RestartSec = "5s";
StandardOutput = "journal";
StandardError = "journal";
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = "read-only";
PrivateTmp = true;
ReadOnlyPaths = [ "${inputs.self}" ];
};
};
}

View File

@@ -22,6 +22,7 @@
victron_modbuss = "!include ${./home_assistant/victron_modbuss.yaml}";
battery_sensors = "!include ${./home_assistant/battery_sensors.yaml}";
gps_location = "!include ${./home_assistant/gps_location.yaml}";
heater = "!include ${./home_assistant/heater.yaml}";
van_weather = "!include ${./home_assistant/van_weather_template.yaml}";
};
};
@@ -71,6 +72,7 @@
pymetno # for met.no weather
uiprotect # for ubiquiti integration
unifi-discovery # for ubiquiti integration
jsonpath # for rest sensors
];
extraComponents = [ "isal" ];
customComponents = with pkgs.home-assistant-custom-components; [

View File

@@ -0,0 +1,41 @@
rest:
- resource: http://localhost:8124/status
scan_interval: 30
sensor:
- name: "Heater Setpoint"
unique_id: heater_setpoint
value_template: "{{ value_json.setpoint }}"
unit_of_measurement: "F"
device_class: temperature
- name: "Heater State"
unique_id: heater_state
value_template: "{{ value_json.state }}"
- name: "Heater Error Code"
unique_id: heater_error_code
value_template: "{{ value_json.error_code }}"
binary_sensor:
- name: "Heater Power"
unique_id: heater_power
value_template: "{{ value_json.power }}"
device_class: running
rest_command:
heater_on:
url: http://localhost:8124/on
method: POST
heater_off:
url: http://localhost:8124/off
method: POST
heater_toggle:
url: http://localhost:8124/toggle
method: POST
template:
- switch:
- unique_id: heater_switch
name: Heater
state: "{{ is_state('binary_sensor.heater_power', 'on') }}"
turn_on:
- action: rest_command.heater_on
turn_off:
- action: rest_command.heater_off