mirror of
https://github.com/RichieCahill/dotfiles.git
synced 2026-04-17 04:58:19 -04:00
created python heater to contron the hln heater
This commit is contained in:
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
1
python/heater/__init__.py
Normal file
1
python/heater/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tuya heater control service."""
|
||||
68
python/heater/controller.py
Normal file
68
python/heater/controller.py
Normal 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
85
python/heater/main.py
Normal 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
31
python/heater/models.py
Normal 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
|
||||
33
systems/brain/services/heater.nix
Normal file
33
systems/brain/services/heater.nix
Normal 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}" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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; [
|
||||
|
||||
41
systems/brain/services/home_assistant/heater.yaml
Normal file
41
systems/brain/services/home_assistant/heater.yaml
Normal 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
|
||||
Reference in New Issue
Block a user