mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
The reporter's A1 had been offline 38 hours and still answered on 8883, so the connection watchdog did exactly what it exists for: rebuild the session with a fresh client, since anything left in the old one's QoS 1 queue would otherwise replay onto the next print (#1136). The rebuild ended in paho's loop_stop(), which sets a terminate flag and then joins the network thread with no timeout. That thread only reads the flag between iterations of loop_forever, so it cannot read it while parked inside reconnect() -> _ssl_wrap_socket() -> do_handshake(). paho gives that handshake the keepalive as its socket timeout -- 30s here -- and a socket timeout is per operation, renewed by every byte the peer sends. A printer that answers TCP and then trickles holds the join open for as long as it likes. The join ran on the asyncio thread. Bambuddy stopped answering anything -- UI, API, /health -- while the process stayed up, which is why a restart: unless-stopped container never restarted. Retiring a client no longer waits for it. The replacement is built at once and the old one is shut down on a thread of its own that nobody joins. Its callbacks are detached first, inline: blocking until the network thread was gone is what used to guarantee a client we had let go of could no longer touch our state, and with the teardown detached a zombie that finishes its handshake would otherwise auto-reconnect and report itself connected behind its replacement's back. disconnect() still goes out, still promptly, because that is what stops paho's auto-reconnect and the replay with it. The reported watchdog is one of six callers. The queue's dispatch recovery and check_staleness -- reached from an ordinary status poll -- share _hard_reset_client; editing, deleting and hand-disconnecting a printer share disconnect(); the relay and smart-plug services had the same join on their shutdown path, where a wedged broker stopped the process from exiting at all. #1445 was this join too, from the add-printer probe, and its off-loop teardown stays as it is. disconnect() stays quiet on the way out, as it always effectively did. paho's callback used to land during the join, but it suppresses itself for a clean disconnect of a printer that reported in the last ten seconds, so a healthy printer disconnected by hand never announced itself offline. Announcing it now would tell the user their printer had gone offline a minute after they disconnected it on purpose (#1752). A retirement that takes more than five seconds logs which printer it was. The whole point is that the next one of these should not have to be diagnosed from a thread dump.
532 lines
21 KiB
Python
532 lines
21 KiB
Python
"""MQTT Smart Plug Service for subscribing to external MQTT topics and extracting power/energy data.
|
|
|
|
This service enables integration with Shelly, Zigbee2MQTT, and other MQTT-based energy monitoring devices.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
import paho.mqtt.client as mqtt
|
|
|
|
from backend.app.utils.paho_teardown import retire_paho_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class SmartPlugMQTTData:
|
|
"""Latest data received from an MQTT smart plug."""
|
|
|
|
plug_id: int
|
|
power: float | None = None # Current power in watts
|
|
energy: float | None = None # Energy in kWh (today)
|
|
state: str | None = None # "ON" or "OFF"
|
|
last_seen: datetime = field(default_factory=datetime.utcnow)
|
|
|
|
|
|
@dataclass
|
|
class MQTTDataSourceConfig:
|
|
"""Configuration for a single MQTT data source (power, energy, or state)."""
|
|
|
|
topic: str
|
|
path: str
|
|
multiplier: float = 1.0 # For power/energy
|
|
on_value: str | None = None # For state (what value means "ON")
|
|
|
|
|
|
class MQTTSmartPlugService:
|
|
"""Subscribes to MQTT topics for smart plug energy monitoring."""
|
|
|
|
# Consider plug unreachable if no message received in this time
|
|
REACHABLE_TIMEOUT_MINUTES = 5
|
|
|
|
def __init__(self):
|
|
self.client: mqtt.Client | None = None
|
|
self.connected = False
|
|
self._lock = threading.Lock()
|
|
# topic -> list of (plug_id, data_type) where data_type is "power", "energy", or "state"
|
|
self.subscriptions: dict[str, list[tuple[int, str]]] = {}
|
|
# plug_id -> {data_type: MQTTDataSourceConfig}
|
|
self.plug_configs: dict[int, dict[str, MQTTDataSourceConfig]] = {}
|
|
# plug_id -> latest data
|
|
self.plug_data: dict[int, SmartPlugMQTTData] = {}
|
|
self._disconnection_event: threading.Event | None = None
|
|
self._configured = False
|
|
self._broker = ""
|
|
self._port = 1883
|
|
self._username = ""
|
|
self._password = ""
|
|
self._use_tls = False
|
|
|
|
def is_configured(self) -> bool:
|
|
"""Check if the MQTT service is configured and connected."""
|
|
return self._configured and self.connected
|
|
|
|
def has_broker_settings(self) -> bool:
|
|
"""Check if broker settings are available (even if not connected yet)."""
|
|
return bool(self._broker)
|
|
|
|
async def configure(self, settings: dict) -> bool:
|
|
"""Configure MQTT connection from settings.
|
|
|
|
Uses the same broker settings as the MQTT relay service.
|
|
Returns True if connection was successful or MQTT is disabled.
|
|
"""
|
|
enabled = settings.get("mqtt_enabled", False)
|
|
|
|
if not enabled:
|
|
await self.disconnect()
|
|
self._configured = False
|
|
logger.debug("MQTT smart plug service disabled (MQTT relay not enabled)")
|
|
return True
|
|
|
|
broker = settings.get("mqtt_broker", "")
|
|
port = settings.get("mqtt_port", 1883)
|
|
username = settings.get("mqtt_username", "")
|
|
password = settings.get("mqtt_password", "")
|
|
use_tls = settings.get("mqtt_use_tls", False)
|
|
|
|
if not broker:
|
|
logger.warning("MQTT smart plug service: no broker configured")
|
|
self._configured = False
|
|
return False
|
|
|
|
# Check if settings changed
|
|
settings_changed = (
|
|
self._broker != broker
|
|
or self._port != port
|
|
or self._username != username
|
|
or self._password != password
|
|
or self._use_tls != use_tls
|
|
)
|
|
|
|
self._broker = broker
|
|
self._port = port
|
|
self._username = username
|
|
self._password = password
|
|
self._use_tls = use_tls
|
|
self._configured = True
|
|
|
|
# Disconnect and reconnect if settings changed
|
|
if settings_changed and self.client:
|
|
await self.disconnect()
|
|
|
|
# Connect if not already connected
|
|
if not self.client or not self.connected:
|
|
return await self._connect()
|
|
|
|
return True
|
|
|
|
async def _connect(self) -> bool:
|
|
"""Establish MQTT connection."""
|
|
import asyncio
|
|
import ssl
|
|
|
|
try:
|
|
# Create client with callback API version 2
|
|
self.client = mqtt.Client(
|
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
|
client_id=f"bambuddy-smartplug-{id(self)}",
|
|
protocol=mqtt.MQTTv311,
|
|
)
|
|
|
|
# Set up callbacks
|
|
self.client.on_connect = self._on_connect
|
|
self.client.on_disconnect = self._on_disconnect
|
|
self.client.on_message = self._on_message
|
|
|
|
# Configure authentication
|
|
if self._username:
|
|
self.client.username_pw_set(self._username, self._password)
|
|
|
|
# Configure TLS
|
|
if self._use_tls:
|
|
self.client.tls_set(cert_reqs=ssl.CERT_NONE)
|
|
self.client.tls_insecure_set(True)
|
|
|
|
# Connect with timeout
|
|
try:
|
|
await asyncio.wait_for(
|
|
asyncio.to_thread(self.client.connect_async, self._broker, self._port, 60),
|
|
timeout=3.0,
|
|
)
|
|
except TimeoutError:
|
|
logger.warning("MQTT smart plug connection to %s:%s timed out", self._broker, self._port)
|
|
return False
|
|
|
|
self.client.loop_start()
|
|
|
|
# Wait briefly for connection
|
|
await asyncio.sleep(1.0)
|
|
|
|
if self.connected:
|
|
logger.info("MQTT smart plug service connected to %s:%s", self._broker, self._port)
|
|
# Resubscribe to all topics
|
|
self._resubscribe_all()
|
|
return True
|
|
else:
|
|
logger.warning("MQTT smart plug connection pending to %s:%s", self._broker, self._port)
|
|
return True # Connection is async
|
|
|
|
except Exception as e:
|
|
logger.error("MQTT smart plug connection failed: %s", e)
|
|
self.connected = False
|
|
return False
|
|
|
|
def _on_connect(
|
|
self,
|
|
client: mqtt.Client,
|
|
userdata: Any,
|
|
flags: dict,
|
|
reason_code: int | mqtt.ReasonCode,
|
|
properties: mqtt.Properties | None = None,
|
|
):
|
|
"""Callback when connected to broker."""
|
|
rc = reason_code if isinstance(reason_code, int) else reason_code.value
|
|
if rc == 0:
|
|
self.connected = True
|
|
logger.info("MQTT smart plug service connected successfully")
|
|
# Resubscribe to all topics
|
|
self._resubscribe_all()
|
|
else:
|
|
self.connected = False
|
|
logger.error("MQTT smart plug connection failed: %s", reason_code)
|
|
|
|
def _on_disconnect(
|
|
self,
|
|
client: mqtt.Client,
|
|
userdata: Any,
|
|
flags_or_rc: dict | int | mqtt.ReasonCode,
|
|
reason_code: int | mqtt.ReasonCode | None = None,
|
|
properties: mqtt.Properties | None = None,
|
|
):
|
|
"""Callback when disconnected from broker."""
|
|
self.connected = False
|
|
rc = reason_code if reason_code is not None else flags_or_rc
|
|
rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0)
|
|
if rc_val != 0:
|
|
logger.warning("MQTT smart plug service disconnected: %s", rc)
|
|
else:
|
|
logger.info("MQTT smart plug service disconnected cleanly")
|
|
if self._disconnection_event:
|
|
self._disconnection_event.set()
|
|
|
|
def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage):
|
|
"""Handle incoming MQTT message, extract data using JSON path."""
|
|
topic = msg.topic
|
|
|
|
with self._lock:
|
|
subscriptions = self.subscriptions.get(topic, [])
|
|
if not subscriptions:
|
|
return
|
|
|
|
# Parse JSON payload (or treat as raw value)
|
|
try:
|
|
payload = json.loads(msg.payload.decode("utf-8"))
|
|
is_json = True
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
# Not JSON - treat the whole payload as a raw value
|
|
payload = msg.payload.decode("utf-8").strip()
|
|
is_json = False
|
|
|
|
# Process for each subscribed (plug_id, data_type)
|
|
for plug_id, data_type in subscriptions:
|
|
configs = self.plug_configs.get(plug_id, {})
|
|
config = configs.get(data_type)
|
|
if not config:
|
|
continue
|
|
|
|
# Extract value using path (or use raw payload if no path)
|
|
if is_json and config.path:
|
|
raw_value = self._extract_json_path(payload, config.path)
|
|
elif is_json and not config.path:
|
|
# JSON but no path - if it's a simple value use it, otherwise skip
|
|
if isinstance(payload, (int, float, str, bool)):
|
|
raw_value = payload
|
|
else:
|
|
# Can't use a dict/list as a value
|
|
logger.debug("MQTT plug %s: JSON payload is object/array but no path configured", plug_id)
|
|
continue
|
|
else:
|
|
# Raw value (non-JSON)
|
|
raw_value = payload
|
|
|
|
if raw_value is None:
|
|
continue
|
|
|
|
# Initialize plug data if needed
|
|
if plug_id not in self.plug_data:
|
|
self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
|
|
|
|
data = self.plug_data[plug_id]
|
|
data.last_seen = datetime.now(timezone.utc)
|
|
|
|
# Process based on data type
|
|
if data_type == "power":
|
|
try:
|
|
data.power = float(raw_value) * config.multiplier
|
|
logger.debug("MQTT smart plug %s: power=%s", plug_id, data.power)
|
|
except (ValueError, TypeError):
|
|
pass # Ignore unparseable power reading from MQTT
|
|
|
|
elif data_type == "energy":
|
|
try:
|
|
data.energy = float(raw_value) * config.multiplier
|
|
logger.debug("MQTT smart plug %s: energy=%s", plug_id, data.energy)
|
|
except (ValueError, TypeError):
|
|
pass # Ignore unparseable energy reading from MQTT
|
|
|
|
elif data_type == "state":
|
|
state_str = str(raw_value)
|
|
# Check against configured ON value if set
|
|
if config.on_value:
|
|
# Case-insensitive comparison
|
|
if state_str.lower() == config.on_value.lower():
|
|
data.state = "ON"
|
|
else:
|
|
data.state = "OFF"
|
|
else:
|
|
# Default behavior: normalize common values
|
|
upper_state = state_str.upper()
|
|
if upper_state in ("ON", "1", "TRUE"):
|
|
data.state = "ON"
|
|
elif upper_state in ("OFF", "0", "FALSE"):
|
|
data.state = "OFF"
|
|
else:
|
|
data.state = state_str
|
|
logger.debug("MQTT smart plug %s: state=%s", plug_id, data.state)
|
|
|
|
def _extract_json_path(self, data: dict, path: str) -> Any:
|
|
"""Extract value using dot notation (e.g., 'power_l1' or 'data.power').
|
|
|
|
Supports simple dot notation for nested objects.
|
|
"""
|
|
if not path:
|
|
return None
|
|
|
|
parts = path.split(".")
|
|
current = data
|
|
|
|
for part in parts:
|
|
if isinstance(current, dict) and part in current:
|
|
current = current[part]
|
|
else:
|
|
return None
|
|
|
|
return current
|
|
|
|
def _resubscribe_all(self):
|
|
"""Resubscribe to all registered topics after reconnection."""
|
|
if not self.client or not self.connected:
|
|
return
|
|
|
|
with self._lock:
|
|
for topic in self.subscriptions:
|
|
if self.subscriptions[topic]: # Only if there are subscribers
|
|
try:
|
|
self.client.subscribe(topic, qos=1)
|
|
logger.debug("MQTT smart plug: resubscribed to %s", topic)
|
|
except Exception as e:
|
|
logger.error("MQTT smart plug: failed to resubscribe to %s: %s", topic, e)
|
|
|
|
def subscribe(
|
|
self,
|
|
plug_id: int,
|
|
# Power source
|
|
power_topic: str | None = None,
|
|
power_path: str | None = None,
|
|
power_multiplier: float = 1.0,
|
|
# Energy source
|
|
energy_topic: str | None = None,
|
|
energy_path: str | None = None,
|
|
energy_multiplier: float = 1.0,
|
|
# State source
|
|
state_topic: str | None = None,
|
|
state_path: str | None = None,
|
|
state_on_value: str | None = None,
|
|
# Legacy: single topic/path/multiplier (for backward compatibility)
|
|
topic: str | None = None,
|
|
multiplier: float = 1.0,
|
|
):
|
|
"""Subscribe to MQTT topics for a plug.
|
|
|
|
Each data type (power, energy, state) can have its own topic.
|
|
For backward compatibility, if power_topic is not set but topic is,
|
|
topic will be used for all data types that have paths configured.
|
|
"""
|
|
with self._lock:
|
|
# Initialize config for this plug
|
|
self.plug_configs[plug_id] = {}
|
|
|
|
# Determine topics (new fields take priority, fall back to legacy)
|
|
effective_power_topic = power_topic or topic
|
|
effective_energy_topic = energy_topic or topic
|
|
effective_state_topic = state_topic or topic
|
|
|
|
# Use new multipliers or fall back to legacy
|
|
effective_power_mult = power_multiplier if power_multiplier != 1.0 else multiplier
|
|
effective_energy_mult = energy_multiplier if energy_multiplier != 1.0 else multiplier
|
|
|
|
# Configure power subscription (path is optional - empty means use raw payload)
|
|
if effective_power_topic:
|
|
config = MQTTDataSourceConfig(
|
|
topic=effective_power_topic,
|
|
path=power_path or "",
|
|
multiplier=effective_power_mult,
|
|
)
|
|
self.plug_configs[plug_id]["power"] = config
|
|
self._add_subscription(plug_id, effective_power_topic, "power")
|
|
|
|
# Configure energy subscription (path is optional - empty means use raw payload)
|
|
if effective_energy_topic:
|
|
config = MQTTDataSourceConfig(
|
|
topic=effective_energy_topic,
|
|
path=energy_path or "",
|
|
multiplier=effective_energy_mult,
|
|
)
|
|
self.plug_configs[plug_id]["energy"] = config
|
|
self._add_subscription(plug_id, effective_energy_topic, "energy")
|
|
|
|
# Configure state subscription (path is optional - empty means use raw payload)
|
|
if effective_state_topic:
|
|
config = MQTTDataSourceConfig(
|
|
topic=effective_state_topic,
|
|
path=state_path or "",
|
|
on_value=state_on_value,
|
|
)
|
|
self.plug_configs[plug_id]["state"] = config
|
|
self._add_subscription(plug_id, effective_state_topic, "state")
|
|
|
|
# Initialize data entry
|
|
if plug_id not in self.plug_data:
|
|
self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
|
|
|
|
logger.info(
|
|
f"MQTT smart plug {plug_id}: configured with "
|
|
f"power={effective_power_topic if power_path else None}, "
|
|
f"energy={effective_energy_topic if energy_path else None}, "
|
|
f"state={effective_state_topic if state_path else None}"
|
|
)
|
|
|
|
def _add_subscription(self, plug_id: int, topic: str, data_type: str):
|
|
"""Add a subscription for a plug/data_type to a topic."""
|
|
if topic not in self.subscriptions:
|
|
self.subscriptions[topic] = []
|
|
# Actually subscribe if connected
|
|
if self.client and self.connected:
|
|
try:
|
|
self.client.subscribe(topic, qos=1)
|
|
logger.info("MQTT smart plug: subscribed to %s", topic)
|
|
except Exception as e:
|
|
logger.error("MQTT smart plug: failed to subscribe to %s: %s", topic, e)
|
|
|
|
entry = (plug_id, data_type)
|
|
if entry not in self.subscriptions[topic]:
|
|
self.subscriptions[topic].append(entry)
|
|
|
|
def unsubscribe(self, plug_id: int):
|
|
"""Unsubscribe when plug is deleted/updated."""
|
|
with self._lock:
|
|
# Get all configs for this plug
|
|
configs = self.plug_configs.pop(plug_id, {})
|
|
if not configs:
|
|
# Still clean up any stray subscriptions
|
|
pass
|
|
|
|
# Collect all topics this plug was subscribed to
|
|
topics_to_check = set()
|
|
for _data_type, config in configs.items():
|
|
topics_to_check.add(config.topic)
|
|
|
|
# Also scan subscriptions to remove any entries for this plug
|
|
for topic in list(self.subscriptions.keys()):
|
|
# Remove all entries for this plug_id
|
|
self.subscriptions[topic] = [(pid, dtype) for pid, dtype in self.subscriptions[topic] if pid != plug_id]
|
|
topics_to_check.add(topic)
|
|
|
|
# Unsubscribe from topics with no more subscribers
|
|
for topic in topics_to_check:
|
|
if topic in self.subscriptions and not self.subscriptions[topic]:
|
|
del self.subscriptions[topic]
|
|
if self.client and self.connected:
|
|
try:
|
|
self.client.unsubscribe(topic)
|
|
logger.info("MQTT smart plug: unsubscribed from %s", topic)
|
|
except Exception as e:
|
|
logger.error("MQTT smart plug: failed to unsubscribe from %s: %s", topic, e)
|
|
|
|
# Remove data
|
|
self.plug_data.pop(plug_id, None)
|
|
|
|
def get_plug_data(self, plug_id: int) -> SmartPlugMQTTData | None:
|
|
"""Get latest data for a plug (called by status endpoint)."""
|
|
with self._lock:
|
|
return self.plug_data.get(plug_id)
|
|
|
|
def is_reachable(self, plug_id: int) -> bool:
|
|
"""Check if a plug has received data recently."""
|
|
data = self.get_plug_data(plug_id)
|
|
if not data:
|
|
return False
|
|
|
|
timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES)
|
|
return datetime.now(timezone.utc) - data.last_seen < timeout
|
|
|
|
async def disconnect(self, timeout: float = 0):
|
|
"""Disconnect from MQTT broker."""
|
|
if self.client:
|
|
try:
|
|
self._disconnection_event = threading.Event()
|
|
self.client.disconnect()
|
|
await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)
|
|
retire_paho_client(self.client, "smart-plugs")
|
|
except Exception as e:
|
|
logger.debug("MQTT smart plug disconnect error (ignored): %s", e)
|
|
finally:
|
|
self.client = None
|
|
self.connected = False
|
|
|
|
|
|
def subscribe_plug_to_mqtt(service: "MQTTSmartPlugService", plug: Any) -> list[str]:
|
|
"""Resolve per-type topic fields on a SmartPlug and register it with the service.
|
|
|
|
The SmartPlug model carries both a legacy single `mqtt_topic` and newer
|
|
per-type `mqtt_{power,energy,state}_topic` fields. Three code paths used
|
|
to open-code this resolution (startup restore, create, update) and they
|
|
drifted — the startup path skipped plugs that only had per-type topics
|
|
set, leaving them unsubscribed after every restart (#1010). Funnelling
|
|
all three through this helper keeps them in sync.
|
|
|
|
Returns the list of topics subscribed (empty if nothing was configured).
|
|
"""
|
|
power_topic = plug.mqtt_power_topic or plug.mqtt_topic
|
|
energy_topic = plug.mqtt_energy_topic or plug.mqtt_topic
|
|
state_topic = plug.mqtt_state_topic or plug.mqtt_topic
|
|
|
|
if not (power_topic or energy_topic or state_topic):
|
|
return []
|
|
|
|
legacy_mult = plug.mqtt_multiplier or 1.0
|
|
service.subscribe(
|
|
plug_id=plug.id,
|
|
power_topic=power_topic,
|
|
power_path=plug.mqtt_power_path,
|
|
power_multiplier=plug.mqtt_power_multiplier or legacy_mult,
|
|
energy_topic=energy_topic,
|
|
energy_path=plug.mqtt_energy_path,
|
|
energy_multiplier=plug.mqtt_energy_multiplier or legacy_mult,
|
|
state_topic=state_topic,
|
|
state_path=plug.mqtt_state_path,
|
|
state_on_value=plug.mqtt_state_on_value,
|
|
)
|
|
return [t for t in {power_topic, energy_topic, state_topic} if t]
|
|
|
|
|
|
# Global instance
|
|
mqtt_smart_plug_service = MQTTSmartPlugService()
|