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.
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user