Files
bambuddy/backend/app/schemas/smart_plug.py
T
maziggy d6935c9253 Add Home Assistant energy sensor entity support (Issue #119)
Home Assistant smart plugs can now use separate sensor entities for
energy monitoring, enabling energy tracking for plugs that expose
power/energy data as separate sensors (Tapo, IKEA Zigbee2mqtt, etc.).

Features:
- Configure dedicated power (W), today (kWh), and total (kWh) sensors
- New API endpoint GET /api/v1/smart-plugs/ha/sensors lists available sensors
- Falls back to switch entity attributes if no sensors configured
- Print energy tracking now works for HA plugs (not just Tasmota)

Backend:
- Added ha_power_entity, ha_energy_today_entity, ha_energy_total_entity
  fields to SmartPlug model
- Updated get_energy() to fetch from configured sensor entities
- Added _get_plug_energy() helper to handle both plug types
- Updated backup/restore to include new fields
- Added database migration for new columns

Frontend:
- Added energy sensor dropdowns in AddSmartPlugModal (shown for HA plugs)
- Dropdowns filtered by unit (W/kW for power, kWh/Wh for energy)

Tests:
- Added 4 new integration tests for HA energy sensor functionality

Docs:
- Updated README with new feature
- Updated CHANGELOG with 0.1.6b11 entry
2026-01-22 09:04:40 +01:00

160 lines
5.8 KiB
Python

from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, model_validator
class SmartPlugBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
plug_type: Literal["tasmota", "homeassistant"] = "tasmota"
# Tasmota fields (required when plug_type="tasmota")
ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
username: str | None = None
password: str | None = None
# Home Assistant fields (required when plug_type="homeassistant")
ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean)\.[a-z0-9_]+$")
# Home Assistant energy sensor entities (optional, for separate energy sensors)
ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
ha_energy_total_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
printer_id: int | None = None
enabled: bool = True
auto_on: bool = True
auto_off: bool = True
off_delay_mode: Literal["time", "temperature"] = "time"
off_delay_minutes: int = Field(default=5, ge=0, le=60)
off_temp_threshold: int = Field(default=70, ge=30, le=150)
# Power alerts
power_alert_enabled: bool = False
power_alert_high: float | None = Field(default=None, ge=0, le=5000) # Alert when power > this (watts)
power_alert_low: float | None = Field(default=None, ge=0, le=5000) # Alert when power < this (watts)
# Schedule
schedule_enabled: bool = False
schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format
schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format
# Switchbar visibility
show_in_switchbar: bool = False
@model_validator(mode="after")
def validate_plug_type_fields(self) -> "SmartPlugBase":
if self.plug_type == "tasmota" and not self.ip_address:
raise ValueError("ip_address is required for Tasmota plugs")
if self.plug_type == "homeassistant" and not self.ha_entity_id:
raise ValueError("ha_entity_id is required for Home Assistant plugs")
return self
class SmartPlugCreate(SmartPlugBase):
pass
class SmartPlugUpdate(BaseModel):
name: str | None = None
plug_type: Literal["tasmota", "homeassistant"] | None = None
ip_address: str | None = None
ha_entity_id: str | None = None
# Home Assistant energy sensor entities (optional)
ha_power_entity: str | None = None
ha_energy_today_entity: str | None = None
ha_energy_total_entity: str | None = None
printer_id: int | None = None
enabled: bool | None = None
auto_on: bool | None = None
auto_off: bool | None = None
off_delay_mode: Literal["time", "temperature"] | None = None
off_delay_minutes: int | None = Field(default=None, ge=0, le=60)
off_temp_threshold: int | None = Field(default=None, ge=30, le=150)
username: str | None = None
password: str | None = None
# Power alerts
power_alert_enabled: bool | None = None
power_alert_high: float | None = Field(default=None, ge=0, le=5000)
power_alert_low: float | None = Field(default=None, ge=0, le=5000)
# Schedule
schedule_enabled: bool | None = None
schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
# Switchbar visibility
show_in_switchbar: bool | None = None
class SmartPlugResponse(SmartPlugBase):
id: int
last_state: str | None = None
last_checked: datetime | None = None
auto_off_executed: bool = False # True when auto-off was triggered after print
power_alert_last_triggered: datetime | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class SmartPlugControl(BaseModel):
action: Literal["on", "off", "toggle"]
class SmartPlugEnergy(BaseModel):
"""Energy monitoring data from a smart plug."""
power: float | None = None # Current watts
voltage: float | None = None # Volts
current: float | None = None # Amps
today: float | None = None # kWh used today
yesterday: float | None = None # kWh used yesterday
total: float | None = None # Total kWh
factor: float | None = None # Power factor (0-1)
apparent_power: float | None = None # VA
reactive_power: float | None = None # VAr
class SmartPlugStatus(BaseModel):
state: str | None = None # "ON", "OFF", or None if unreachable
reachable: bool = True
device_name: str | None = None
energy: SmartPlugEnergy | None = None # Energy data if available
class SmartPlugTestConnection(BaseModel):
ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
username: str | None = None
password: str | None = None
# Home Assistant schemas
class HATestConnectionRequest(BaseModel):
"""Request to test Home Assistant connection."""
url: str = Field(..., min_length=1)
token: str = Field(..., min_length=1)
class HATestConnectionResponse(BaseModel):
"""Response from HA connection test."""
success: bool
message: str | None = None
error: str | None = None
class HAEntity(BaseModel):
"""A Home Assistant entity that can be used as a smart plug."""
entity_id: str
friendly_name: str
state: str | None = None
domain: str # "switch", "light", "input_boolean"
class HASensorEntity(BaseModel):
"""A Home Assistant sensor entity for energy monitoring."""
entity_id: str
friendly_name: str
state: str | None = None
unit_of_measurement: str | None = None # "W", "kW", "kWh", "Wh"