P4: Production hardening — reconnect, auto-scene-load, service cleanup

This commit is contained in:
2026-06-23 13:27:06 -04:00
parent d19e0ea7a8
commit 84cad11745
21 changed files with 2458 additions and 363 deletions
+139 -136
View File
@@ -1,21 +1,18 @@
"""
OPLabs Mixer — Unix domain socket IPC client.
Connects to the C++ MixerDaemon over a Unix domain socket using a
length-prefixed JSON protocol.
Connects to the C++ MixerDaemon over a Unix domain socket using the
daemon's newline-terminated JSON protocol.
Protocol:
Frame: 4-byte big-endian uint32 length prefix + UTF-8 JSON payload
Request: {"id": <int>, "method": <string>, "params": {<object>}}
Response: {"id": <int>, "result": <any>, "error": <string|null>}
Push: {"type": "push", "event": <string>, "data": {<object>}}
Send: JSON payload + newline ('\\n')
Receive: JSON payload + newline ('\\n')
Each connection handles one request batch (send → receive → close).
"""
import asyncio
import json
import logging
import struct
from typing import Any, Optional
logger = logging.getLogger("mixer-ipc")
@@ -28,6 +25,11 @@ class MixerIpcError(Exception):
class MixerIpcClient:
"""Async Unix socket IPC client for the OPLabs Mixer Daemon.
Each request opens a fresh connection to the daemon, sends the
command as a newline-terminated JSON line, reads the response,
and closes. This matches the daemon's line-based protocol where
one connection = one request batch.
Usage:
client = MixerIpcClient(socket_path="/tmp/oplabs-mixer.sock")
await client.connect()
@@ -46,41 +48,42 @@ class MixerIpcClient:
self._writer: Optional[asyncio.StreamWriter] = None
self._connected = False
self._next_id = 1
self._pending: dict[int, asyncio.Future] = {}
self._read_task: Optional[asyncio.Task] = None
# ------------------------------------------------------------------
# Connection lifecycle
# ------------------------------------------------------------------
async def connect(self) -> bool:
"""Connect to the Unix domain socket. Returns True on success."""
"""Probe the Unix domain socket to verify daemon is reachable.
Opens a test connection to the daemon and immediately closes it.
The daemon's protocol is connection-per-request, so we don't
hold a persistent connection.
"""
try:
loop = asyncio.get_running_loop()
self._reader, self._writer = await asyncio.wait_for(
reader, writer = await asyncio.wait_for(
asyncio.open_unix_connection(self._socket_path),
timeout=self._connect_timeout,
)
# Test succeeded — daemon is listening. Close immediately.
writer.close()
await writer.wait_closed()
self._connected = True
self._read_task = asyncio.create_task(self._read_loop())
logger.info("Connected to mixer daemon at %s", self._socket_path)
return True
except (FileNotFoundError, ConnectionRefusedError, asyncio.TimeoutError,
OSError) as exc:
logger.warning("Failed to connect to mixer daemon: %s", exc)
self._connected = False
logger.warning("Daemon not reachable at %s: %s", self._socket_path, exc)
return False
async def close(self):
"""Close the connection."""
self._connected = False
if self._read_task:
self._read_task.cancel()
try:
await self._read_task
except asyncio.CancelledError:
pass
self._read_task = None
await self._disconnect()
async def _disconnect(self):
"""Close the underlying socket."""
if self._writer:
try:
self._writer.close()
@@ -88,108 +91,82 @@ class MixerIpcClient:
except Exception:
pass
self._writer = None
self._reader = None
async def _ensure_connection(self):
"""Open a fresh connection to the daemon."""
await self._disconnect()
try:
loop = asyncio.get_running_loop()
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_unix_connection(self._socket_path),
timeout=self._connect_timeout,
)
except (FileNotFoundError, ConnectionRefusedError, asyncio.TimeoutError,
OSError) as exc:
self._writer = None
self._reader = None
raise MixerIpcError(f"Cannot connect to daemon: {exc}")
@property
def is_connected(self) -> bool:
return self._connected
# ------------------------------------------------------------------
# Low-level send / receive
# Send / receive (line-based protocol, one-shot connection)
# ------------------------------------------------------------------
async def _send_frame(self, payload: bytes):
"""Send a length-prefixed frame."""
length = struct.pack("!I", len(payload))
self._writer.write(length + payload)
await self._writer.drain()
async def _read_loop(self):
"""Background task: read frames and dispatch responses."""
try:
while self._connected and self._reader:
# Read 4-byte length prefix
header = await self._reader.readexactly(4)
length = struct.unpack("!I", header)[0]
if length == 0:
continue
# Read JSON payload
data = await self._reader.readexactly(length)
payload = data.decode("utf-8")
try:
msg = json.loads(payload)
except json.JSONDecodeError:
logger.warning("Invalid JSON from daemon: %s", payload[:200])
continue
# Dispatch
if isinstance(msg, dict):
if "type" in msg and msg["type"] == "push":
# Push message (VU meter updates, state changes)
await self._handle_push(msg)
elif "id" in msg:
# Response to a request
req_id = msg["id"]
future = self._pending.pop(req_id, None)
if future and not future.done():
if msg.get("error"):
future.set_exception(
MixerIpcError(msg["error"])
)
else:
future.set_result(msg.get("result"))
except asyncio.IncompleteReadError:
logger.info("Mixer daemon connection closed")
except asyncio.CancelledError:
pass
except Exception as exc:
logger.warning("Read loop error: %s", exc)
finally:
self._connected = False
# Fail all pending requests
for future in self._pending.values():
if not future.done():
future.set_exception(MixerIpcError("Connection closed"))
self._pending.clear()
async def _send_request(self, method: str, params: dict = None) -> Any:
"""Send a request and wait for the response."""
if not self._connected or not self._writer:
"""Open connection, send request, wait for response, close.
Wire format: ``{"cmd": method, **params}`` (daemon's native format).
The daemon expects ``cmd`` as the command field and all parameters
at the top level (not nested under a ``params`` key).
"""
if not self._connected:
raise MixerIpcError("Not connected to mixer daemon")
req_id = self._next_id
self._next_id += 1
# Build daemon-native wire message: {"cmd": method, ...params}
msg: dict[str, Any] = {"cmd": method}
if params:
msg.update(params)
msg = {
"id": req_id,
"method": method,
"params": params or {},
}
future: asyncio.Future = asyncio.get_running_loop().create_future()
self._pending[req_id] = future
# Open fresh connection
await self._ensure_connection()
try:
payload = json.dumps(msg).encode("utf-8")
await self._send_frame(payload)
# Send: JSON payload + newline
payload = json.dumps(msg) + "\n"
self._writer.write(payload.encode("utf-8"))
await self._writer.drain()
# Read: one response line
response_line = await asyncio.wait_for(
self._reader.readline(),
timeout=self._request_timeout,
)
if not response_line:
raise MixerIpcError("Empty response from daemon")
response = json.loads(response_line.decode("utf-8").strip())
# Check for error status
if isinstance(response, dict):
if response.get("status") == "error":
raise MixerIpcError(response.get("message", "Unknown error"))
return response
result = await asyncio.wait_for(future, timeout=self._request_timeout)
return result
except asyncio.TimeoutError:
self._pending.pop(req_id, None)
raise MixerIpcError(f"Request '{method}' timed out")
async def _handle_push(self, msg: dict):
"""Handle an unsolicited push message from the daemon.
Override in subclass or attach a callback.
"""
event = msg.get("event", "unknown")
data = msg.get("data")
logger.debug("Push event: %s", event)
# Subclasses can override to handle VU meter updates, etc.
except json.JSONDecodeError as e:
raise MixerIpcError(f"Invalid JSON response: {e}")
except MixerIpcError:
raise
except Exception as e:
raise MixerIpcError(f"Request '{method}' failed: {e}")
finally:
await self._disconnect()
# ------------------------------------------------------------------
# High-level API
@@ -206,44 +183,92 @@ class MixerIpcClient:
async def set_channel_volume(self, channel_index: int, volume_db: float):
"""Set channel volume in dB (-inf to +12)."""
await self._send_request("setChannelVolume", {
"channelIndex": channel_index,
"volumeDb": volume_db,
"channel": channel_index,
"volume": volume_db,
})
async def set_channel_pan(self, channel_index: int, pan: float):
"""Set channel pan (-1.0 left to +1.0 right)."""
await self._send_request("setChannelPan", {
"channelIndex": channel_index,
"channel": channel_index,
"pan": pan,
})
async def set_channel_mute(self, channel_index: int, mute: bool):
"""Set channel mute state."""
await self._send_request("setChannelMute", {
"channelIndex": channel_index,
"channel": channel_index,
"mute": mute,
})
async def set_channel_solo(self, channel_index: int, solo: bool):
"""Set channel solo state."""
await self._send_request("setChannelSolo", {
"channelIndex": channel_index,
"channel": channel_index,
"solo": solo,
})
async def set_channel_label(self, channel_index: int, label: str):
"""Set the label/name for a channel."""
await self._send_request("setChannelLabel", {
"channel": channel_index,
"label": label,
})
# --- Channel Lifecycle ---
async def add_channel(self, physical_input_index: int = 0) -> int:
"""Add a new channel. Returns the channel index."""
result = await self._send_request("addChannel", {
"physicalInputIndex": physical_input_index,
"physicalInput": physical_input_index,
})
return result.get("channelIndex", -1)
async def remove_channel(self, channel_index: int):
"""Remove a channel by index."""
await self._send_request("removeChannel", {
"channelIndex": channel_index,
"channel": channel_index,
})
# --- Bus Control ---
async def set_bus_volume(self, bus_id: int, volume_db: float):
"""Set bus volume in dB (-inf to +12)."""
await self._send_request("setBusVolume", {
"busId": bus_id,
"volume": volume_db,
})
async def set_bus_mute(self, bus_id: int, mute: bool):
"""Set bus mute state."""
await self._send_request("setBusMute", {
"busId": bus_id,
"mute": mute,
})
# --- Routing ---
async def route_channel_to_bus(self, channel_index: int, bus_id: int, level_db: float = 0.0):
"""Route a channel to a bus with a given level in dB."""
await self._send_request("routeChannelToBus", {
"channel": channel_index,
"busId": bus_id,
"level": level_db,
})
async def route_bus_to_bus(self, source_bus_id: int, target_bus_id: int, level_db: float = 0.0):
"""Route one bus to another bus."""
await self._send_request("routeBusToBus", {
"sourceBusId": source_bus_id,
"targetBusId": target_bus_id,
"level": level_db,
})
async def remove_route(self, source_id: int, target_bus_id: int):
"""Remove a route."""
await self._send_request("removeRoute", {
"sourceId": source_id,
"targetBusId": target_bus_id,
})
# --- Output Routing ---
@@ -264,28 +289,6 @@ class MixerIpcClient:
"routes": routes,
})
# --- Scenes ---
async def save_scene(self, name: str) -> dict:
"""Save the current mixer state as a named scene.
Returns {"id": int, "name": str}."""
return await self._send_request("saveScene", {"name": name})
async def load_scene(self, scene_id: str) -> bool:
"""Load (recall) a scene by its ID. Returns True on success."""
result = await self._send_request("loadScene", {"sceneId": scene_id})
return result.get("ok", False)
async def list_scenes(self) -> list[dict]:
"""List all available scenes."""
result = await self._send_request("listScenes")
return result.get("scenes", [])
async def delete_scene(self, scene_id: str) -> bool:
"""Delete a scene by ID. Returns True on success."""
result = await self._send_request("deleteScene", {"sceneId": scene_id})
return result.get("ok", False)
# ------------------------------------------------------------------
# Convenience context manager