Source code for nwp500.mqtt.command_queue
"""
MQTT command queue management for Navien Smart Control.
This module handles queueing of commands when the MQTT connection is lost,
and automatically sends them when the connection is restored.
"""
import logging
from collections import deque
from collections.abc import Callable
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from .types import QoS
from .utils import QueuedCommand, redact_topic
if TYPE_CHECKING:
from .utils import MqttConnectionConfig
__author__ = "Emmanuel Levijarvi"
__copyright__ = "Emmanuel Levijarvi"
__license__ = "MIT"
_logger = logging.getLogger(__name__)
[docs]
class MqttCommandQueue:
"""
Manages command queueing when MQTT connection is interrupted.
Commands sent while disconnected are queued and automatically sent
when the connection is restored. This ensures commands are not lost
during temporary network interruptions.
The queue uses a deque with a fixed maximum size. When the queue is
full, the oldest command is automatically dropped to make room for
new commands (FIFO with overflow dropping). Commands older than
``config.max_queued_command_age`` seconds are discarded at send time
rather than replayed to the device.
"""
def __init__(self, config: MqttConnectionConfig):
"""
Initialize the command queue.
Args:
config: MQTT connection configuration with queue settings
"""
self.config = config
# A deque (not asyncio.Queue) so a command that fails mid-flush
# can be re-inserted at the FRONT, preserving command order.
self._queue: deque[QueuedCommand] = deque(
maxlen=config.max_queued_commands
)
[docs]
def enqueue(self, topic: str, payload: dict[str, Any], qos: QoS) -> None:
"""
Add a command to the queue.
If the queue is full, the oldest command is dropped to make room
for the new one (FIFO with overflow dropping).
Args:
topic: MQTT topic
payload: Command payload
qos: Quality of Service level
"""
if not self.config.enable_command_queue:
_logger.warning(
f"Command queue disabled, dropping command to "
f"'{redact_topic(topic)}'. Enable command queue in "
f"config to queue commands when disconnected."
)
return
command = QueuedCommand(
topic=topic,
payload=payload,
qos=qos,
timestamp=datetime.now(UTC),
)
# deque(maxlen=N) drops from the opposite end automatically, but
# log the overflow explicitly for visibility.
if len(self._queue) == self._queue.maxlen:
dropped = self._queue.popleft()
_logger.warning(
f"Command queue full ({self.config.max_queued_commands}), "
f"dropped oldest command to '{redact_topic(dropped.topic)}'"
)
self._queue.append(command)
_logger.info(f"Queued command (queue size: {len(self._queue)})")
[docs]
async def send_all(
self,
publish_func: Callable[..., Any],
is_connected_func: Callable[[], bool],
) -> tuple[int, int]:
"""
Send all queued commands.
This is called automatically when connection is restored.
Args:
publish_func: Async function to publish messages (topic, payload,
qos)
is_connected_func: Function to check if currently connected
Returns:
Tuple of (sent_count, failed_count)
"""
if not self._queue:
return (0, 0)
queue_size = len(self._queue)
_logger.info(f"Sending {queue_size} queued command(s)...")
sent_count = 0
failed_count = 0
max_age = self.config.max_queued_command_age
now = datetime.now(UTC)
while self._queue and is_connected_func():
command = self._queue.popleft()
# Don't replay stale control commands (e.g. an hours-old
# set_power) to a physical appliance after a long outage.
age = (now - command.timestamp).total_seconds()
if max_age is not None and age > max_age:
_logger.warning(
f"Discarding expired queued command to "
f"'{redact_topic(command.topic)}' "
f"(age {age:.0f}s > max {max_age:.0f}s)"
)
continue
try:
# Publish the queued command
await publish_func(
topic=command.topic,
payload=command.payload,
qos=command.qos,
)
sent_count += 1
_logger.debug(
f"Sent queued command to '{redact_topic(command.topic)}' "
f"(queued at {command.timestamp.isoformat()})"
)
except Exception as e:
failed_count += 1
_logger.error(
f"Failed to send queued command to "
f"'{redact_topic(command.topic)}': {e}"
)
# Re-queue at the FRONT so command order is preserved on
# the next flush (re-appending would invert order-
# sensitive sequences like power_on -> set_temp).
self._queue.appendleft(command)
break # Stop processing on error to avoid cascade failures
if sent_count > 0:
_logger.info(
f"Sent {sent_count} queued command(s)"
+ (f", {failed_count} failed" if failed_count > 0 else "")
)
return (sent_count, failed_count)
[docs]
def clear(self) -> int:
"""
Clear all queued commands.
Returns:
Number of commands cleared
"""
cleared = len(self._queue)
self._queue.clear()
if cleared > 0:
_logger.info(f"Cleared {cleared} queued command(s)")
return cleared
@property
def count(self) -> int:
"""Get the number of queued commands."""
return len(self._queue)
@property
def is_empty(self) -> bool:
"""Check if the queue is empty."""
return not self._queue
@property
def is_full(self) -> bool:
"""Check if the queue is full."""
return len(self._queue) == self._queue.maxlen