P4: Production hardening — reconnect, auto-scene-load, service cleanup
This commit is contained in:
+139
-136
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user