"""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