diff --git a/overlays/default.nix b/overlays/default.nix index 51f646b..0d27c4e 100644 --- a/overlays/default.nix +++ b/overlays/default.nix @@ -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 ] diff --git a/pyproject.toml b/pyproject.toml index dc108df..87d19c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 ] diff --git a/python/heater/__init__.py b/python/heater/__init__.py new file mode 100644 index 0000000..26c1925 --- /dev/null +++ b/python/heater/__init__.py @@ -0,0 +1 @@ +"""Tuya heater control service.""" diff --git a/python/heater/controller.py b/python/heater/controller.py new file mode 100644 index 0000000..e369921 --- /dev/null +++ b/python/heater/controller.py @@ -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() diff --git a/python/heater/main.py b/python/heater/main.py new file mode 100644 index 0000000..0d7ecf7 --- /dev/null +++ b/python/heater/main.py @@ -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) diff --git a/python/heater/models.py b/python/heater/models.py new file mode 100644 index 0000000..23c050a --- /dev/null +++ b/python/heater/models.py @@ -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 diff --git a/systems/brain/services/heater.nix b/systems/brain/services/heater.nix new file mode 100644 index 0000000..94bc21f --- /dev/null +++ b/systems/brain/services/heater.nix @@ -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}" ]; + }; + }; +} diff --git a/systems/brain/services/home_assistant.nix b/systems/brain/services/home_assistant.nix index 5676052..ee8b7f5 100644 --- a/systems/brain/services/home_assistant.nix +++ b/systems/brain/services/home_assistant.nix @@ -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; [ diff --git a/systems/brain/services/home_assistant/heater.yaml b/systems/brain/services/home_assistant/heater.yaml new file mode 100644 index 0000000..0bcfbc6 --- /dev/null +++ b/systems/brain/services/home_assistant/heater.yaml @@ -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