71803418e5
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.
319 lines
11 KiB
Python
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
|