Files
shelly-ui/src/shelly_manager/api/mass_config_apply.py
T
jonas 71803418e5 Initial commit: Shelly Manager with Textual CLI, Streamlit UI, and comprehensive .gitignore
Shelly device management app with mDNS/subnet discovery, inventory,
configuration, and mass operations for Gen1/Gen2+ devices.

Includes .gitignore excluding runtime data (device DB, user config),
AI conversation history, build artifacts, and common Python/OS patterns.
2026-03-23 21:51:59 +01:00

319 lines
11 KiB
Python

"""Apply mass configuration operations via aioshelly (Gen2+ RPC)."""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable
from aioshelly.common import ConnectionOptions
from aioshelly.exceptions import RpcCallError, ShellyError
from aioshelly.rpc_device.device import RpcDevice
from pydantic import BaseModel
from shelly_manager.api.client import fetch_device_snapshot
from shelly_manager.api.context import ShellyRuntime
from shelly_manager.core.mass_config import get_mass_operation
from shelly_manager.core.models import ShellyDevice, preserve_firmware_check_metadata
_LOGGER = logging.getLogger(__name__)
RPC_METHOD_NOT_FOUND = -114
def _rpc_error_detail(err: RpcCallError) -> str:
"""Human-readable RPC failure for UI and logs."""
msg = (err.message or "").strip() or str(err)
return f"RPC {err.code}: {msg}"
class MassApplyResult(BaseModel):
"""Result of applying one mass-config operation to one device."""
device_id: str
display_name: str
status: str # ok | skipped | error
detail: str = ""
restart_required: bool = False
async def _with_rpc(
runtime: ShellyRuntime,
device: ShellyDevice,
fn: Callable[[RpcDevice], Awaitable[MassApplyResult]],
) -> MassApplyResult:
"""Run async callable(dev: RpcDevice) with a connected device (respects ``http_port``)."""
if runtime.session is None:
raise RuntimeError("ShellyRuntime not entered")
port = int(device.http_port or 80)
opts = ConnectionOptions(ip_address=device.ip, port=port)
dev = await RpcDevice.create(runtime.session, None, opts)
await dev.initialize()
try:
return await fn(dev)
finally:
await dev.shutdown()
async def _apply_ble_disable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
async def go(dev: RpcDevice) -> MassApplyResult:
out: dict | object
try:
out = await dev.ble_setconfig(False, False)
except RpcCallError as err:
if err.code == RPC_METHOD_NOT_FOUND:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail="BLE.SetConfig not available on this model/firmware",
)
# Some firmware rejects nested ``rpc`` when disabling — try minimal payload.
try:
out = await dev.call_rpc("BLE.SetConfig", {"config": {"enable": False}})
except RpcCallError as err2:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="error",
detail=_rpc_error_detail(err2),
)
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="ok",
detail="BLE.SetConfig disable",
restart_required=rr,
)
return await _with_rpc(runtime, device, go)
async def _apply_ble_enable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
async def go(dev: RpcDevice) -> MassApplyResult:
out: dict | object
try:
out = await dev.ble_setconfig(True, True)
except RpcCallError as err:
if err.code == RPC_METHOD_NOT_FOUND:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail="BLE.SetConfig not available on this model/firmware",
)
try:
out = await dev.call_rpc(
"BLE.SetConfig",
{"config": {"enable": True, "rpc": {"enable": True}}},
)
except RpcCallError as err2:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="error",
detail=_rpc_error_detail(err2),
)
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="ok",
detail="BLE.SetConfig enable",
restart_required=rr,
)
return await _with_rpc(runtime, device, go)
async def _apply_mqtt_disable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
async def go(dev: RpcDevice) -> MassApplyResult:
try:
out = await dev.call_rpc("MQTT.SetConfig", {"config": {"enable": False}})
except RpcCallError as err:
if getattr(err, "code", None) == RPC_METHOD_NOT_FOUND:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail="MQTT.SetConfig not available on this model",
)
raise
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="ok",
detail="MQTT.SetConfig enable=false",
restart_required=rr,
)
return await _with_rpc(runtime, device, go)
async def _apply_mqtt_enable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
async def go(dev: RpcDevice) -> MassApplyResult:
try:
out = await dev.call_rpc("MQTT.SetConfig", {"config": {"enable": True}})
except RpcCallError as err:
if getattr(err, "code", None) == RPC_METHOD_NOT_FOUND:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail="MQTT.SetConfig not available on this model",
)
raise
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="ok",
detail="MQTT.SetConfig enable=true",
restart_required=rr,
)
return await _with_rpc(runtime, device, go)
async def _apply_cloud_disable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
async def go(dev: RpcDevice) -> MassApplyResult:
try:
out = await dev.call_rpc("Cloud.SetConfig", {"config": {"enable": False}})
except RpcCallError as err:
if getattr(err, "code", None) == RPC_METHOD_NOT_FOUND:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail="Cloud.SetConfig not available on this model",
)
raise
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="ok",
detail="Cloud.SetConfig enable=false",
restart_required=rr,
)
return await _with_rpc(runtime, device, go)
async def _apply_cloud_enable(runtime: ShellyRuntime, device: ShellyDevice) -> MassApplyResult:
async def go(dev: RpcDevice) -> MassApplyResult:
try:
out = await dev.call_rpc("Cloud.SetConfig", {"config": {"enable": True}})
except RpcCallError as err:
if getattr(err, "code", None) == RPC_METHOD_NOT_FOUND:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail="Cloud.SetConfig not available on this model",
)
raise
rr = bool(out.get("restart_required")) if isinstance(out, dict) else False
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="ok",
detail="Cloud.SetConfig enable=true",
restart_required=rr,
)
return await _with_rpc(runtime, device, go)
_HANDLERS = {
"ble_disable": _apply_ble_disable,
"ble_enable": _apply_ble_enable,
"mqtt_disable": _apply_mqtt_disable,
"mqtt_enable": _apply_mqtt_enable,
"cloud_disable": _apply_cloud_disable,
"cloud_enable": _apply_cloud_enable,
}
async def apply_mass_operation(
runtime: ShellyRuntime,
device: ShellyDevice,
operation_id: str,
) -> MassApplyResult:
"""Apply a single **RPC config** operation to one device (caller persists refreshed snapshot)."""
meta = get_mass_operation(operation_id)
if not meta:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="error",
detail=f"Unknown operation {operation_id!r}",
)
if meta.kind != "rpc_config":
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="error",
detail=f"Operation {operation_id!r} is not an RPC config action — use DeviceManager.mass_apply_operation",
)
if device.generation not in meta.supported_generations:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail=f"Not supported for generation {device.generation}",
)
if device.auth_required:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="skipped",
detail="Authentication required — configure credentials first",
)
handler = _HANDLERS.get(operation_id)
if not handler:
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="error",
detail=f"No handler implemented for {operation_id!r}",
)
try:
return await handler(runtime, device)
except ShellyError as err:
_LOGGER.warning("mass apply %s on %s: %s", operation_id, device.id, err)
detail = _rpc_error_detail(err) if isinstance(err, RpcCallError) else str(err)
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="error",
detail=detail,
)
except Exception as err: # noqa: BLE001
_LOGGER.exception("mass apply %s on %s", operation_id, device.id)
return MassApplyResult(
device_id=device.id,
display_name=device.display_name,
status="error",
detail=str(err),
)
async def refresh_device_after_apply(
runtime: ShellyRuntime,
device: ShellyDevice,
) -> ShellyDevice | None:
"""Fetch fresh Shelly snapshot and preserve tags/notes."""
fresh = await fetch_device_snapshot(runtime, device.ip, device.generation)
fresh.tags = device.tags
fresh.notes = device.notes
fresh.http_port = device.http_port
preserve_firmware_check_metadata(fresh, device)
return fresh