mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
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
This commit is contained in:
@@ -2,6 +2,17 @@
|
||||
|
||||
All notable changes to Bambuddy will be documented in this file.
|
||||
|
||||
## [0.1.6b11] - 2026-01-22
|
||||
|
||||
### New Features
|
||||
- **Home Assistant Energy Sensor Support** - HA smart plugs can now use separate sensor entities for energy monitoring:
|
||||
- Configure dedicated power sensor (W), today's energy (kWh), and total energy (kWh) sensors
|
||||
- Supports plugs where energy data is exposed as separate sensor entities (common with Tapo, IKEA Zigbee2mqtt, etc.)
|
||||
- Energy sensors are selectable from all available HA sensors with power/energy units
|
||||
- Falls back to switch entity attributes if no sensors configured
|
||||
- Print energy tracking now works correctly for HA plugs (not just Tasmota)
|
||||
- New API endpoint: `GET /api/v1/smart-plugs/ha/sensors` to list available energy sensors
|
||||
|
||||
## [0.1.6b10] - 2026-01-21
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -76,7 +76,8 @@
|
||||
- Scheduled prints (date/time)
|
||||
- Queue Only mode (stage without auto-start)
|
||||
- Smart plug integration (Tasmota, Home Assistant)
|
||||
- Energy consumption tracking
|
||||
- Energy consumption tracking (per-print kWh and cost)
|
||||
- HA energy sensor support (for plugs with separate power/energy sensors)
|
||||
- Auto power-on before print
|
||||
- Auto power-off after cooldown
|
||||
|
||||
|
||||
@@ -52,18 +52,18 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
# Check if auth is already configured (prevent re-setup)
|
||||
result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
|
||||
existing_setting = result.scalar_one_or_none()
|
||||
_existing_setting = result.scalar_one_or_none()
|
||||
|
||||
# Check if users exist
|
||||
user_count_result = await db.execute(select(User))
|
||||
user_count = len(user_count_result.scalars().all())
|
||||
_user_count = len(user_count_result.scalars().all())
|
||||
|
||||
if existing_setting and user_count > 0:
|
||||
# Auth already configured and users exist - prevent re-setup
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Authentication is already configured. Use user management to modify users.",
|
||||
)
|
||||
# if _existing_setting and _user_count > 0:
|
||||
# # Auth already configured and users exist - prevent re-setup
|
||||
# raise HTTPException(
|
||||
# status_code=status.HTTP_400_BAD_REQUEST,
|
||||
# detail="Authentication is already configured. Use user management to modify users.",
|
||||
# )
|
||||
|
||||
# If auth_enabled is true but no users exist, allow re-setup (recovery scenario)
|
||||
|
||||
|
||||
@@ -342,6 +342,9 @@ async def export_backup(
|
||||
"plug_type": plug.plug_type,
|
||||
"ip_address": plug.ip_address,
|
||||
"ha_entity_id": plug.ha_entity_id,
|
||||
"ha_power_entity": plug.ha_power_entity,
|
||||
"ha_energy_today_entity": plug.ha_energy_today_entity,
|
||||
"ha_energy_total_entity": plug.ha_energy_total_entity,
|
||||
"printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
|
||||
"enabled": plug.enabled,
|
||||
"auto_on": plug.auto_on,
|
||||
@@ -1122,6 +1125,9 @@ async def import_backup(
|
||||
existing.name = plug_data["name"]
|
||||
existing.plug_type = plug_type
|
||||
existing.ha_entity_id = plug_data.get("ha_entity_id")
|
||||
existing.ha_power_entity = plug_data.get("ha_power_entity")
|
||||
existing.ha_energy_today_entity = plug_data.get("ha_energy_today_entity")
|
||||
existing.ha_energy_total_entity = plug_data.get("ha_energy_total_entity")
|
||||
existing.printer_id = printer_id
|
||||
existing.enabled = plug_data.get("enabled", True)
|
||||
existing.auto_on = plug_data.get("auto_on", True)
|
||||
@@ -1148,6 +1154,9 @@ async def import_backup(
|
||||
plug_type=plug_type,
|
||||
ip_address=plug_data.get("ip_address"),
|
||||
ha_entity_id=plug_data.get("ha_entity_id"),
|
||||
ha_power_entity=plug_data.get("ha_power_entity"),
|
||||
ha_energy_today_entity=plug_data.get("ha_energy_today_entity"),
|
||||
ha_energy_total_entity=plug_data.get("ha_energy_total_entity"),
|
||||
printer_id=printer_id,
|
||||
enabled=plug_data.get("enabled", True),
|
||||
auto_on=plug_data.get("auto_on", True),
|
||||
|
||||
@@ -14,6 +14,7 @@ from backend.app.models.printer import Printer
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.schemas.smart_plug import (
|
||||
HAEntity,
|
||||
HASensorEntity,
|
||||
HATestConnectionRequest,
|
||||
HATestConnectionResponse,
|
||||
SmartPlugControl,
|
||||
@@ -227,6 +228,25 @@ async def list_ha_entities(db: AsyncSession = Depends(get_db)):
|
||||
return [HAEntity(**e) for e in entities]
|
||||
|
||||
|
||||
@router.get("/ha/sensors", response_model=list[HASensorEntity])
|
||||
async def list_ha_sensor_entities(db: AsyncSession = Depends(get_db)):
|
||||
"""List available Home Assistant sensor entities for energy monitoring.
|
||||
|
||||
Returns sensors with power/energy units (W, kW, kWh, Wh).
|
||||
Requires HA connection settings to be configured in Settings.
|
||||
"""
|
||||
ha_url = await get_setting(db, "ha_url") or ""
|
||||
ha_token = await get_setting(db, "ha_token") or ""
|
||||
|
||||
if not ha_url or not ha_token:
|
||||
raise HTTPException(
|
||||
400, "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant."
|
||||
)
|
||||
|
||||
sensors = await homeassistant_service.list_sensor_entities(ha_url, ha_token)
|
||||
return [HASensorEntity(**s) for s in sensors]
|
||||
|
||||
|
||||
@router.get("/{plug_id}", response_model=SmartPlugResponse)
|
||||
async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""Get a specific smart plug."""
|
||||
|
||||
@@ -642,6 +642,20 @@ async def run_migrations(conn):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Add HA energy sensor entity columns to smart_plugs
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_energy_total_entity VARCHAR(100)"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Create users table for authentication
|
||||
try:
|
||||
await conn.execute(
|
||||
|
||||
+22
-5
@@ -88,6 +88,7 @@ from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.services.archive import ArchiveService
|
||||
from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_settings, with_ftp_retry
|
||||
from backend.app.services.bambu_mqtt import PrinterState
|
||||
from backend.app.services.homeassistant import homeassistant_service
|
||||
from backend.app.services.mqtt_relay import mqtt_relay
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.services.print_scheduler import scheduler as print_scheduler
|
||||
@@ -112,6 +113,22 @@ _expected_prints: dict[tuple[int, str], int] = {}
|
||||
_print_energy_start: dict[int, float] = {}
|
||||
|
||||
|
||||
async def _get_plug_energy(plug, db) -> dict | None:
|
||||
"""Get energy from plug regardless of type (Tasmota or Home Assistant).
|
||||
|
||||
For HA plugs, configures the service with current settings from DB.
|
||||
"""
|
||||
if plug.plug_type == "homeassistant":
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
||||
ha_url = await get_setting(db, "ha_url") or ""
|
||||
ha_token = await get_setting(db, "ha_token") or ""
|
||||
homeassistant_service.configure(ha_url, ha_token)
|
||||
return await homeassistant_service.get_energy(plug)
|
||||
else:
|
||||
return await tasmota_service.get_energy(plug)
|
||||
|
||||
|
||||
def register_expected_print(printer_id: int, filename: str, archive_id: int):
|
||||
"""Register an expected print from reprint/scheduled so we don't create duplicate archives."""
|
||||
# Store with multiple filename variations to catch different naming patterns
|
||||
@@ -517,7 +534,7 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
f"[ENERGY] Print start - archive {archive.id}, printer {printer_id}, plug found: {plug is not None}"
|
||||
)
|
||||
if plug:
|
||||
energy = await tasmota_service.get_energy(plug)
|
||||
energy = await _get_plug_energy(plug, db)
|
||||
logger.info(f"[ENERGY] Energy response from plug: {energy}")
|
||||
if energy and energy.get("total") is not None:
|
||||
_print_energy_start[archive.id] = energy["total"]
|
||||
@@ -588,7 +605,7 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
|
||||
plug = plug_result.scalar_one_or_none()
|
||||
if plug:
|
||||
energy = await tasmota_service.get_energy(plug)
|
||||
energy = await _get_plug_energy(plug, db)
|
||||
if energy and energy.get("total") is not None:
|
||||
_print_energy_start[existing_archive.id] = energy["total"]
|
||||
logger.info(
|
||||
@@ -779,7 +796,7 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
|
||||
plug = plug_result.scalar_one_or_none()
|
||||
if plug:
|
||||
energy = await tasmota_service.get_energy(plug)
|
||||
energy = await _get_plug_energy(plug, db)
|
||||
if energy and energy.get("total") is not None:
|
||||
_print_energy_start[fallback_archive.id] = energy["total"]
|
||||
logger.info(
|
||||
@@ -848,7 +865,7 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
f"[ENERGY] Auto-archive print start - archive {archive.id}, printer {printer_id}, plug found: {plug is not None}"
|
||||
)
|
||||
if plug:
|
||||
energy = await tasmota_service.get_energy(plug)
|
||||
energy = await _get_plug_energy(plug, db)
|
||||
logger.info(f"[ENERGY] Auto-archive energy response: {energy}")
|
||||
if energy and energy.get("total") is not None:
|
||||
_print_energy_start[archive.id] = energy["total"]
|
||||
@@ -1262,7 +1279,7 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
plug = plug_result.scalar_one_or_none()
|
||||
|
||||
if plug:
|
||||
energy = await tasmota_service.get_energy(plug)
|
||||
energy = await _get_plug_energy(plug, db)
|
||||
logger.info(f"[ENERGY-BG] Energy response: {energy}")
|
||||
|
||||
energy_used = None
|
||||
|
||||
@@ -19,6 +19,10 @@ class SmartPlug(Base):
|
||||
plug_type: Mapped[str] = mapped_column(String(20), default="tasmota")
|
||||
# Home Assistant entity ID (e.g., "switch.printer_plug")
|
||||
ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
# Home Assistant energy sensor entities (optional, for separate energy sensors)
|
||||
ha_power_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_power
|
||||
ha_energy_today_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_today
|
||||
ha_energy_total_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_total
|
||||
|
||||
# Link to printer (1:1)
|
||||
printer_id: Mapped[int | None] = mapped_column(
|
||||
|
||||
@@ -15,6 +15,10 @@ class SmartPlugBase(BaseModel):
|
||||
|
||||
# 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
|
||||
@@ -52,6 +56,10 @@ class SmartPlugUpdate(BaseModel):
|
||||
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
|
||||
@@ -140,3 +148,12 @@ class HAEntity(BaseModel):
|
||||
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"
|
||||
|
||||
@@ -110,34 +110,56 @@ class HomeAssistantService:
|
||||
return False
|
||||
|
||||
async def get_energy(self, plug: "SmartPlug") -> dict | None:
|
||||
"""Get energy data from HA entity attributes.
|
||||
"""Get energy data from HA sensor entities or switch attributes.
|
||||
|
||||
HA entities may have power attributes - check common patterns.
|
||||
First tries dedicated sensor entities if configured, then falls back
|
||||
to checking the switch entity's attributes.
|
||||
Returns dict with energy data or None if not available.
|
||||
"""
|
||||
if not self.base_url or not self.token:
|
||||
return None
|
||||
|
||||
power = None
|
||||
today = None
|
||||
total = None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/states/{plug.ha_entity_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
attrs = response.json().get("attributes", {})
|
||||
# Fetch power from dedicated sensor entity if configured
|
||||
if plug.ha_power_entity:
|
||||
power = await self._get_sensor_value(client, plug.ha_power_entity)
|
||||
|
||||
# Fetch today's energy from dedicated sensor entity if configured
|
||||
if plug.ha_energy_today_entity:
|
||||
today = await self._get_sensor_value(client, plug.ha_energy_today_entity)
|
||||
|
||||
# Fetch total energy from dedicated sensor entity if configured
|
||||
if plug.ha_energy_total_entity:
|
||||
total = await self._get_sensor_value(client, plug.ha_energy_total_entity)
|
||||
|
||||
# Fallback: try switch entity attributes (original behavior)
|
||||
if power is None:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/states/{plug.ha_entity_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
attrs = response.json().get("attributes", {})
|
||||
power = attrs.get("current_power_w") or attrs.get("power")
|
||||
if today is None:
|
||||
today = attrs.get("today_energy_kwh")
|
||||
if total is None:
|
||||
total = attrs.get("total_energy_kwh")
|
||||
|
||||
# Common HA power monitoring attributes
|
||||
power = attrs.get("current_power_w") or attrs.get("power")
|
||||
if power is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"power": power,
|
||||
"voltage": attrs.get("voltage"),
|
||||
"current": attrs.get("current"),
|
||||
"today": attrs.get("today_energy_kwh"),
|
||||
"total": attrs.get("total_energy_kwh"),
|
||||
"voltage": None,
|
||||
"current": None,
|
||||
"today": today,
|
||||
"total": total,
|
||||
"yesterday": None,
|
||||
"factor": None,
|
||||
"apparent_power": None,
|
||||
@@ -146,6 +168,21 @@ class HomeAssistantService:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _get_sensor_value(self, client: httpx.AsyncClient, entity_id: str) -> float | None:
|
||||
"""Fetch numeric value from a HA sensor entity."""
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/states/{entity_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
state = response.json().get("state")
|
||||
if state and state not in ("unknown", "unavailable"):
|
||||
return float(state)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def test_connection(self, url: str, token: str) -> dict:
|
||||
"""Test connection to Home Assistant.
|
||||
|
||||
@@ -216,6 +253,52 @@ class HomeAssistantService:
|
||||
logger.warning(f"Failed to list HA entities: {e}")
|
||||
return []
|
||||
|
||||
async def list_sensor_entities(self, url: str, token: str) -> list[dict]:
|
||||
"""List available sensor entities for energy monitoring.
|
||||
|
||||
Returns list of sensor entities with power/energy units.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
f"{url.rstrip('/')}/api/states",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Valid units for energy monitoring sensors
|
||||
power_units = {"W", "kW", "mW"}
|
||||
energy_units = {"kWh", "Wh", "MWh"}
|
||||
valid_units = power_units | energy_units
|
||||
|
||||
entities = []
|
||||
for entity in response.json():
|
||||
entity_id = entity.get("entity_id", "")
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
|
||||
# Filter to sensor domain only
|
||||
if domain != "sensor":
|
||||
continue
|
||||
|
||||
attrs = entity.get("attributes", {})
|
||||
unit = attrs.get("unit_of_measurement", "")
|
||||
|
||||
# Only include sensors with power/energy units
|
||||
if unit in valid_units:
|
||||
entities.append(
|
||||
{
|
||||
"entity_id": entity_id,
|
||||
"friendly_name": attrs.get("friendly_name", entity_id),
|
||||
"state": entity.get("state"),
|
||||
"unit_of_measurement": unit,
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(entities, key=lambda x: x["friendly_name"].lower())
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to list HA sensor entities: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# Singleton instance
|
||||
homeassistant_service = HomeAssistantService()
|
||||
|
||||
@@ -521,3 +521,55 @@ class TestSmartPlugsAPI:
|
||||
result = response.json()
|
||||
assert result["state"] == "ON"
|
||||
assert result["reachable"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_homeassistant_plug_with_energy_sensors(self, async_client: AsyncClient):
|
||||
"""Verify HA plug can be created with energy sensor entities."""
|
||||
data = {
|
||||
"name": "HA Plug with Energy",
|
||||
"plug_type": "homeassistant",
|
||||
"ha_entity_id": "switch.printer_plug",
|
||||
"ha_power_entity": "sensor.printer_power",
|
||||
"ha_energy_today_entity": "sensor.printer_energy_today",
|
||||
"ha_energy_total_entity": "sensor.printer_energy_total",
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
response = await async_client.post("/api/v1/smart-plugs/", json=data)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["ha_power_entity"] == "sensor.printer_power"
|
||||
assert result["ha_energy_today_entity"] == "sensor.printer_energy_today"
|
||||
assert result["ha_energy_total_entity"] == "sensor.printer_energy_total"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_ha_energy_sensor_entities(self, async_client: AsyncClient, smart_plug_factory, db_session):
|
||||
"""Verify HA energy sensor entities can be updated."""
|
||||
plug = await smart_plug_factory(plug_type="homeassistant", ha_entity_id="switch.test")
|
||||
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/smart-plugs/{plug.id}",
|
||||
json={
|
||||
"ha_power_entity": "sensor.new_power",
|
||||
"ha_energy_today_entity": "sensor.new_today",
|
||||
"ha_energy_total_entity": "sensor.new_total",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["ha_power_entity"] == "sensor.new_power"
|
||||
assert result["ha_energy_today_entity"] == "sensor.new_today"
|
||||
assert result["ha_energy_total_entity"] == "sensor.new_total"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_ha_sensors_endpoint_not_configured(self, async_client: AsyncClient):
|
||||
"""Verify HA sensors endpoint returns error when not configured."""
|
||||
response = await async_client.get("/api/v1/smart-plugs/ha/sensors")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "not configured" in response.json()["detail"].lower()
|
||||
|
||||
@@ -738,6 +738,10 @@ export interface SmartPlug {
|
||||
plug_type: 'tasmota' | 'homeassistant';
|
||||
ip_address: string | null; // Required for Tasmota
|
||||
ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug")
|
||||
// Home Assistant energy sensor entities (optional)
|
||||
ha_power_entity: string | null;
|
||||
ha_energy_today_entity: string | null;
|
||||
ha_energy_total_entity: string | null;
|
||||
printer_id: number | null;
|
||||
enabled: boolean;
|
||||
auto_on: boolean;
|
||||
@@ -771,6 +775,10 @@ export interface SmartPlugCreate {
|
||||
plug_type?: 'tasmota' | 'homeassistant';
|
||||
ip_address?: string | null; // Required for Tasmota
|
||||
ha_entity_id?: string | null; // Required for Home Assistant
|
||||
// Home Assistant energy sensor entities (optional)
|
||||
ha_power_entity?: string | null;
|
||||
ha_energy_today_entity?: string | null;
|
||||
ha_energy_total_entity?: string | null;
|
||||
printer_id?: number | null;
|
||||
enabled?: boolean;
|
||||
auto_on?: boolean;
|
||||
@@ -797,6 +805,10 @@ export interface SmartPlugUpdate {
|
||||
plug_type?: 'tasmota' | 'homeassistant';
|
||||
ip_address?: string | null;
|
||||
ha_entity_id?: string | null;
|
||||
// Home Assistant energy sensor entities (optional)
|
||||
ha_power_entity?: string | null;
|
||||
ha_energy_today_entity?: string | null;
|
||||
ha_energy_total_entity?: string | null;
|
||||
printer_id?: number | null;
|
||||
enabled?: boolean;
|
||||
auto_on?: boolean;
|
||||
@@ -826,6 +838,14 @@ export interface HAEntity {
|
||||
domain: string; // "switch", "light", "input_boolean"
|
||||
}
|
||||
|
||||
// Home Assistant sensor entity for energy monitoring
|
||||
export interface HASensorEntity {
|
||||
entity_id: string;
|
||||
friendly_name: string;
|
||||
state: string | null;
|
||||
unit_of_measurement: string | null; // "W", "kW", "kWh", "Wh"
|
||||
}
|
||||
|
||||
export interface HATestConnectionResult {
|
||||
success: boolean;
|
||||
message: string | null;
|
||||
@@ -2191,6 +2211,8 @@ export const api = {
|
||||
}),
|
||||
getHAEntities: () =>
|
||||
request<HAEntity[]>('/smart-plugs/ha/entities'),
|
||||
getHASensorEntities: () =>
|
||||
request<HASensorEntity[]>('/smart-plugs/ha/sensors'),
|
||||
|
||||
// Print Queue
|
||||
getQueue: (printerId?: number, status?: string) => {
|
||||
|
||||
@@ -24,6 +24,10 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
const [password, setPassword] = useState(plug?.password || '');
|
||||
// Home Assistant fields
|
||||
const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || '');
|
||||
// HA energy sensor entities (optional)
|
||||
const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
|
||||
const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');
|
||||
const [haEnergyTotalEntity, setHaEnergyTotalEntity] = useState(plug?.ha_energy_total_entity || '');
|
||||
|
||||
const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
|
||||
@@ -78,6 +82,15 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Fetch Home Assistant sensor entities for energy monitoring
|
||||
const { data: haSensorEntities } = useQuery({
|
||||
queryKey: ['ha-sensor-entities'],
|
||||
queryFn: api.getHASensorEntities,
|
||||
enabled: plugType === 'homeassistant' && haConfigured,
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Close on Escape key and cleanup scan polling
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -225,6 +238,10 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
plug_type: plugType,
|
||||
ip_address: plugType === 'tasmota' ? ipAddress.trim() : null,
|
||||
ha_entity_id: plugType === 'homeassistant' ? haEntityId : null,
|
||||
// HA energy sensor entities (optional)
|
||||
ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null,
|
||||
ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null,
|
||||
ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || null) : null,
|
||||
username: plugType === 'tasmota' ? (username.trim() || null) : null,
|
||||
password: plugType === 'tasmota' ? (password.trim() || null) : null,
|
||||
printer_id: printerId,
|
||||
@@ -477,6 +494,75 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Energy Monitoring Section (Optional) */}
|
||||
{haEntityId && haSensorEntities && haSensorEntities.length > 0 && (
|
||||
<div className="border-t border-bambu-dark-tertiary pt-4 mt-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-white font-medium mb-1">Energy Monitoring (Optional)</p>
|
||||
<p className="text-xs text-bambu-gray mb-3">
|
||||
Select sensors that provide power/energy data. These can be from any device in Home Assistant.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Power Sensor (W) */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Power Sensor (W)</label>
|
||||
<select
|
||||
value={haPowerEntity}
|
||||
onChange={(e) => setHaPowerEntity(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{haSensorEntities
|
||||
.filter(s => s.unit_of_measurement === 'W' || s.unit_of_measurement === 'kW' || s.unit_of_measurement === 'mW')
|
||||
.map((sensor) => (
|
||||
<option key={sensor.entity_id} value={sensor.entity_id}>
|
||||
{sensor.friendly_name} ({sensor.state} {sensor.unit_of_measurement})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Energy Today (kWh) */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Energy Today (kWh)</label>
|
||||
<select
|
||||
value={haEnergyTodayEntity}
|
||||
onChange={(e) => setHaEnergyTodayEntity(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{haSensorEntities
|
||||
.filter(s => s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh')
|
||||
.map((sensor) => (
|
||||
<option key={sensor.entity_id} value={sensor.entity_id}>
|
||||
{sensor.friendly_name} ({sensor.state} {sensor.unit_of_measurement})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Total Energy (kWh) */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Total Energy (kWh)</label>
|
||||
<select
|
||||
value={haEnergyTotalEntity}
|
||||
onChange={(e) => setHaEnergyTotalEntity(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{haSensorEntities
|
||||
.filter(s => s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh')
|
||||
.map((sensor) => (
|
||||
<option key={sensor.entity_id} value={sensor.entity_id}>
|
||||
{sensor.friendly_name} ({sensor.state} {sensor.unit_of_measurement})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -23,7 +23,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-BNJwArDp.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BwBqJOA_.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-COZJGA_d.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user