Files
2026-08-10 21:37:46 -07:00

791 lines
25 KiB
Python

import asyncio
import ipaddress
import socket
from pathlib import Path
import aiohttp
from . import paths
from .profiles import load_yaml, now_iso, save_yaml, slugify
PROBE_TIMEOUT = 1.5
CONTROL_TIMEOUT = 6.0
SCAN_CONCURRENCY = 64
PROFILE_HEADER = """
Shelly device profile.
Generated by: solixauto discover-shelly
This device is an ACTUATOR. The automation engine turns the listed
channels on and off over local HTTP. No cloud service is involved.
channels: switch outputs available on this device.
readable: status fields observed at generation time.
auth: set username/password here if the device requires it.
If the device gets a new IP, either give it a DHCP reservation or set
host: to its mDNS name and rerun discovery.
"""
def local_addresses():
found = []
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
probe.connect(("8.8.8.8", 80))
found.append(probe.getsockname()[0])
except OSError:
pass
finally:
probe.close()
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
found.append(info[4][0])
except socket.gaierror:
pass
try:
import ifaddr
for adapter in ifaddr.get_adapters():
for ip in adapter.ips:
if ip.is_IPv4:
found.append(ip.ip)
except Exception:
pass
usable = []
for address in found:
try:
parsed = ipaddress.ip_address(address)
except ValueError:
continue
if parsed.is_loopback or parsed.is_link_local or not parsed.is_private:
continue
if address not in usable:
usable.append(address)
return usable
def local_subnets():
networks = []
for address in local_addresses():
network = ipaddress.ip_network(f"{address}/24", strict=False)
if network not in networks:
networks.append(network)
return networks
def local_subnet():
networks = local_subnets()
return networks[0] if networks else None
def auth_from(profile):
auth = (profile or {}).get("auth") or {}
username = auth.get("username")
password = auth.get("password")
if username and password:
return aiohttp.BasicAuth(username, password)
return None
async def probe_host(session, host, auth=None):
url = f"http://{host}/shelly"
try:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=PROBE_TIMEOUT), auth=auth
) as response:
if response.status != 200:
return None
payload = await response.json(content_type=None)
except Exception:
return None
if not isinstance(payload, dict):
return None
if not ({"mac", "type", "id", "model"} & set(payload)):
return None
payload["_host"] = str(host)
payload["_gen"] = int(payload.get("gen", 1) or 1)
return payload
async def scan_network(network, verbose=True):
hosts = list(network.hosts())
if verbose:
print(f"Scanning {network} ({len(hosts)} addresses)...")
found = []
semaphore = asyncio.Semaphore(SCAN_CONCURRENCY)
async with aiohttp.ClientSession() as session:
async def worker(host):
async with semaphore:
result = await probe_host(session, host)
if result:
found.append(result)
if verbose:
print(f" found {result['_host']} ({describe(result)})")
await asyncio.gather(*(worker(host) for host in hosts))
return found
async def probe_hosts(hosts, verbose=True):
found = []
async with aiohttp.ClientSession() as session:
for host in hosts:
result = await probe_host(session, host)
if result:
found.append(result)
if verbose:
print(f" found {host} ({describe(result)})")
elif verbose:
print(f" no Shelly at {host}")
return found
def mdns_discover(seconds=5, verbose=True):
try:
from zeroconf import ServiceBrowser, Zeroconf
except ImportError:
if verbose:
print("zeroconf not installed, skipping mDNS (pip install zeroconf)")
return []
import time
discovered = []
class Listener:
def add_service(self, zeroconf_instance, service_type, name):
info = zeroconf_instance.get_service_info(service_type, name, timeout=2000)
if not info:
return
for raw in info.parsed_addresses():
discovered.append(raw)
def update_service(self, *args):
pass
def remove_service(self, *args):
pass
zeroconf_instance = Zeroconf()
listener = Listener()
browsers = [
ServiceBrowser(zeroconf_instance, "_shelly._tcp.local.", listener),
ServiceBrowser(zeroconf_instance, "_http._tcp.local.", listener),
]
if verbose:
print(f"Listening for mDNS announcements for {seconds}s...")
time.sleep(seconds)
for browser in browsers:
browser.cancel()
zeroconf_instance.close()
return sorted(set(discovered))
def describe(payload):
gen = payload.get("_gen", 1)
if gen >= 2:
return f"{payload.get('app') or payload.get('model')} gen{gen}"
return f"{payload.get('type')} gen1"
async def fetch_config(session, host, gen, auth=None):
path = "/rpc/Shelly.GetConfig" if gen >= 2 else "/settings"
url = f"http://{host}{path}"
try:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=CONTROL_TIMEOUT), auth=auth
) as response:
if response.status != 200:
return {}
return await response.json(content_type=None)
except Exception:
return {}
def device_name_from(payload, config, gen):
candidates = []
if gen >= 2:
system = (config or {}).get("sys") or {}
device = system.get("device") or {}
candidates.append(device.get("name"))
else:
candidates.append((config or {}).get("name"))
settings_device = (config or {}).get("device") or {}
candidates.append(settings_device.get("hostname"))
candidates.append((payload or {}).get("name"))
for candidate in candidates:
if candidate and str(candidate).strip():
return str(candidate).strip()
return ""
def channel_name_from(config, gen, index):
if not config:
return ""
if gen >= 2:
entry = config.get(f"switch:{index}") or {}
name = entry.get("name")
else:
relays = config.get("relays") or []
name = relays[index].get("name") if index < len(relays) else None
return str(name).strip() if name else ""
async def fetch_status(session, host, gen, auth=None):
path = "/rpc/Shelly.GetStatus" if gen >= 2 else "/status"
url = f"http://{host}{path}"
try:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=CONTROL_TIMEOUT), auth=auth
) as response:
if response.status != 200:
return {}
return await response.json(content_type=None)
except Exception:
return {}
DAY_NAMES = {
"0": "Sun", "1": "Mon", "2": "Tue", "3": "Wed",
"4": "Thu", "5": "Fri", "6": "Sat", "7": "Sun",
"SUN": "Sun", "MON": "Mon", "TUE": "Tue", "WED": "Wed",
"THU": "Thu", "FRI": "Fri", "SAT": "Sat",
}
def describe_cron(spec):
parts = str(spec or "").split()
if len(parts) != 6:
return str(spec)
second, minute, hour, day, month, weekday = parts
if not (second.isdigit() and minute.isdigit() and hour.isdigit()):
return str(spec)
clock = f"{int(hour):02d}:{int(minute):02d}"
if weekday in ("*", "?") and day in ("*", "?"):
return f"daily at {clock}"
if weekday not in ("*", "?"):
names = []
for token in weekday.replace("-", ",").split(","):
token = token.strip().upper()
names.append(DAY_NAMES.get(token, token))
unique = []
for name in names:
if name not in unique:
unique.append(name)
if len(unique) == 7:
return f"daily at {clock}"
return f"{clock} on {', '.join(unique)}"
return f"{clock} (day {day}, month {month})"
def describe_job(job):
timespec = job.get("timespec") or job.get("cron") or ""
when = describe_cron(timespec)
actions = []
for call in job.get("calls") or []:
method = str(call.get("method") or "")
params = call.get("params") or {}
if method.lower() in ("switch.set", "relay.set"):
state = params.get("on")
if state is None:
state = params.get("turn")
channel = params.get("id", params.get("channel", 0))
if isinstance(state, str):
label = state.upper()
elif state is None:
label = "toggle"
else:
label = "ON" if state else "OFF"
actions.append(f"turn channel {channel} {label}")
elif method:
actions.append(method)
action_text = ", ".join(actions) if actions else "unknown action"
enabled = job.get("enable", job.get("enabled", True))
state = "" if enabled else " [disabled]"
return f"{when}: {action_text}{state}", bool(enabled)
async def rpc(session, host, method, params=None, auth=None):
url = f"http://{host}/rpc/{method}"
try:
async with session.post(
url,
json=params or {},
timeout=aiohttp.ClientTimeout(total=CONTROL_TIMEOUT),
auth=auth,
) as response:
if response.status != 200:
return None
return await response.json(content_type=None)
except Exception:
return None
async def fetch_automation(session, host, gen, config=None, auth=None):
found = {"schedules": [], "webhooks": [], "timers": {}, "checked": True}
if gen >= 2:
schedules = await rpc(session, host, "Schedule.List", auth=auth)
for job in (schedules or {}).get("jobs") or []:
text, enabled = describe_job(job)
found["schedules"].append(
{"id": job.get("id"), "description": text, "enabled": enabled}
)
hooks = await rpc(session, host, "Webhook.List", auth=auth)
for hook in (hooks or {}).get("hooks") or []:
found["webhooks"].append(
{
"id": hook.get("id"),
"event": hook.get("event", "?"),
"name": hook.get("name") or "",
"enabled": bool(hook.get("enable", True)),
}
)
for key, entry in (config or {}).items():
if not key.startswith("switch:"):
continue
index = key.split(":", 1)[1]
timers = {}
if entry.get("auto_on"):
timers["auto_on_after"] = entry.get("auto_on_delay")
if entry.get("auto_off"):
timers["auto_off_after"] = entry.get("auto_off_delay")
if entry.get("initial_state") not in (None, "restore_last"):
timers["initial_state"] = entry.get("initial_state")
if timers:
found["timers"][index] = timers
return found
relays = (config or {}).get("relays") or []
for index, relay in enumerate(relays):
if relay.get("schedule"):
for rule in relay.get("schedule_rules") or []:
found["schedules"].append(
{"description": f"relay {index}: {rule}", "enabled": True}
)
timers = {}
if relay.get("auto_on"):
timers["auto_on_after"] = relay.get("auto_on")
if relay.get("auto_off"):
timers["auto_off_after"] = relay.get("auto_off")
if timers:
found["timers"][str(index)] = timers
for action, hooks in ((config or {}).get("actions") or {}).get("active", {}).items():
found["webhooks"].append({"event": action, "name": "", "enabled": True})
return found
async def delete_schedule(session, host, job_id, gen, auth=None):
if gen >= 2:
result = await rpc(session, host, "Schedule.Delete", {"id": job_id}, auth)
return result is not None
return False
async def delete_webhook(session, host, hook_id, gen, auth=None):
if gen >= 2:
result = await rpc(session, host, "Webhook.Delete", {"id": hook_id}, auth)
return result is not None
return False
async def set_initial_state(session, host, channel, state, gen, auth=None):
if gen >= 2:
result = await rpc(
session,
host,
"Switch.SetConfig",
{"id": int(channel), "config": {"initial_state": state}},
auth,
)
return result is not None
return False
async def clear_auto_timer(session, host, channel, which, gen, auth=None):
if gen < 2:
return False
key = "auto_on" if which == "auto_on_after" else "auto_off"
result = await rpc(
session,
host,
"Switch.SetConfig",
{"id": int(channel), "config": {key: False}},
auth,
)
return result is not None
def automation_warnings(automation, channel=None):
warnings = []
if not automation:
return warnings
for job in automation.get("schedules") or []:
if job.get("enabled"):
warnings.append(f"schedule on the device: {job['description']}")
for index, timers in (automation.get("timers") or {}).items():
if channel is not None and str(channel) != str(index):
continue
for key, value in timers.items():
if key == "initial_state":
if str(value).lower() != "off":
continue
warnings.append(
f"channel {index} powers up to 'off' instead of restoring its "
"last state. After a power cut this plug stays OFF, so anything "
"it charges will not recover on its own"
)
else:
warnings.append(
f"channel {index} has {key} = {value}s, which will undo "
"commands on its own"
)
for hook in automation.get("webhooks") or []:
if hook.get("enabled"):
label = hook.get("name") or hook.get("event")
warnings.append(f"webhook/action on the device: {label}")
return warnings
def extract_channels(payload, status, config=None):
gen = payload.get("_gen", 1)
channels = {}
if gen >= 2:
for key, value in (status or {}).items():
if key.startswith("switch:"):
index = key.split(":", 1)[1]
channels[index] = {
"id": int(index),
"name": channel_name_from(config, gen, int(index))
or value.get("name")
or f"switch {index}",
"has_power_meter": "apower" in value,
}
if not channels:
channels["0"] = {"id": 0, "name": "switch 0", "has_power_meter": False}
return channels
relays = (status or {}).get("relays") or []
count = len(relays) or int(payload.get("num_outputs", 1) or 1)
meters = (status or {}).get("meters") or []
for index in range(count):
channels[str(index)] = {
"id": index,
"name": channel_name_from(config, gen, index) or f"relay {index}",
"has_power_meter": index < len(meters),
}
return channels
def flatten_status(status, prefix="", out=None, depth=0):
if out is None:
out = {}
if depth > 3:
return out
if isinstance(status, dict):
for key, value in status.items():
label = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
if isinstance(value, (dict, list)):
flatten_status(value, label, out, depth + 1)
else:
out[label] = value
elif isinstance(status, list):
for index, value in enumerate(status):
label = f"{prefix}[{index}]"
if isinstance(value, (dict, list)):
flatten_status(value, label, out, depth + 1)
else:
out[label] = value
return out
def find_duplicates(identifier, mac, host, keep):
import yaml
duplicates = []
if not paths.SHELLY_PROFILE_DIR.exists():
return duplicates
markers = {str(v).lower() for v in (identifier, mac, host) if v}
for candidate in sorted(paths.SHELLY_PROFILE_DIR.iterdir()):
if candidate.suffix not in (".yaml", ".yml") or candidate == keep:
continue
try:
with candidate.open("r", encoding="utf-8") as handle:
data = yaml.safe_load(handle)
except Exception:
continue
if not isinstance(data, dict):
continue
identity = data.get("identity") or {}
access = data.get("access") or {}
found = {
str(identity.get("id") or "").lower(),
str(identity.get("mac") or "").lower(),
str(access.get("host") or "").lower(),
}
if markers & (found - {""}):
duplicates.append(candidate)
return duplicates
def build_profile(payload, status, config=None, automation=None):
gen = payload.get("_gen", 1)
host = payload.get("_host")
if gen >= 2:
identifier = payload.get("id") or payload.get("mac")
model = payload.get("model") or payload.get("app") or "unknown"
else:
identifier = payload.get("mac")
model = payload.get("type") or "unknown"
name = device_name_from(payload, config, gen)
readable = {}
for key, value in sorted(flatten_status(status).items()):
readable[key] = value
aliases = []
for candidate in (name, identifier, payload.get("mac"), host):
if candidate and str(candidate) not in aliases:
aliases.append(str(candidate))
return {
"kind": "shelly",
"generated": now_iso(),
"aliases": aliases,
"identity": {
"id": identifier,
"mac": payload.get("mac"),
"make": "Shelly",
"model": model,
"name": name,
"generation": gen,
"firmware": payload.get("ver") or payload.get("fw") or "",
},
"access": {
"transport": "local-http",
"host": host,
"engine_mode": "read-write",
},
"auth": {
"required": bool(payload.get("auth") or payload.get("auth_en")),
"username": "",
"password": "",
},
"channels": extract_channels(payload, status, config),
"device_automation": automation or {},
"readable": readable,
}
async def discover(hosts=None, network=None, use_mdns=True, verbose=True):
paths.ensure_dirs()
candidates = []
if hosts:
candidates.extend(hosts)
else:
if use_mdns:
candidates.extend(mdns_discover(verbose=verbose))
found = []
if candidates:
found.extend(await probe_hosts(sorted(set(candidates)), verbose=verbose))
if not hosts:
if network:
networks = [network]
else:
networks = local_subnets()
if verbose and len(networks) > 1:
print(
f"Detected {len(networks)} local subnet(s): "
+ ", ".join(str(item) for item in networks)
)
if not networks and verbose:
print(
"Could not detect a local subnet. Use --network 192.168.1.0/24 "
"or --host <address>."
)
for target_network in networks:
already = {item["_host"] for item in found}
scanned = await scan_network(target_network, verbose=verbose)
found.extend(item for item in scanned if item["_host"] not in already)
written = []
used_names = {}
async with aiohttp.ClientSession() as session:
for payload in found:
host = payload["_host"]
gen = payload["_gen"]
status = await fetch_status(session, host, gen)
config = await fetch_config(session, host, gen)
automation = await fetch_automation(session, host, gen, config)
profile = build_profile(payload, status, config, automation)
identity = profile["identity"]
identifier = identity["id"] or identity["mac"] or host
friendly = identity.get("name") or ""
if friendly:
stem = slugify(friendly).lower()
if stem in used_names:
suffix = slugify(str(identifier))[-6:]
stem = f"{stem}-{suffix}"
print(
f" note: two devices are named {friendly!r}, "
f"using {stem}.yaml for this one"
)
used_names[stem] = host
else:
stem = f"{slugify(identity['model'])}-{slugify(str(identifier))}".lower()
print(
f" note: {host} has no friendly name set. Name it in the Shelly "
"app and rerun discovery for a readable filename."
)
destination = paths.SHELLY_PROFILE_DIR / f"{stem}.yaml"
stale = find_duplicates(identifier, identity.get("mac"), host, destination)
save_yaml(destination, profile, header=PROFILE_HEADER)
written.append(destination)
for warning in automation_warnings(automation):
print(f" CONFLICT RISK: {warning}")
for other in stale:
print(
f" WARNING: {other.name} also describes this device. "
"Two profiles for one plug will confuse power profiles."
)
print(f" rm {other}")
if verbose:
label = f"{friendly} " if friendly else ""
print(
f" wrote {paths.relative(destination)} "
f"{label}({len(profile['channels'])} channel(s))"
)
return written
class ShellyTarget:
def __init__(self, profile_path, channel=None):
self.profile_path = Path(profile_path)
self.profile = load_yaml(self.profile_path)
identity = self.profile.get("identity", {})
access = self.profile.get("access", {})
self.host = access.get("host")
self.generation = int(identity.get("generation", 1) or 1)
self.model = identity.get("model", "unknown")
self.label = f"{self.model} @ {self.host}"
self.auth = auth_from(self.profile)
channels = self.profile.get("channels") or {}
if channel is None:
channel = sorted(channels)[0] if channels else "0"
self.channel = int(channel)
if not self.host:
raise ValueError(f"{self.profile_path} has no access.host")
async def set_state(self, session, on):
if self.generation >= 2:
url = f"http://{self.host}/rpc/Switch.Set"
payload = {"id": self.channel, "on": bool(on)}
async with session.post(
url,
json=payload,
timeout=aiohttp.ClientTimeout(total=CONTROL_TIMEOUT),
auth=self.auth,
) as response:
if response.status != 200:
raise RuntimeError(f"HTTP {response.status} from {url}")
return await response.json(content_type=None)
turn = "on" if on else "off"
url = f"http://{self.host}/relay/{self.channel}?turn={turn}"
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=CONTROL_TIMEOUT), auth=self.auth
) as response:
if response.status != 200:
raise RuntimeError(f"HTTP {response.status} from {url}")
return await response.json(content_type=None)
async def get_state(self, session):
status = await fetch_status(session, self.host, self.generation, self.auth)
if not status:
return None
if self.generation >= 2:
entry = status.get(f"switch:{self.channel}") or {}
return entry.get("output")
relays = status.get("relays") or []
if self.channel < len(relays):
return relays[self.channel].get("ison")
return None
async def automation(self, session):
config = await fetch_config(session, self.host, self.generation, self.auth)
return await fetch_automation(
session, self.host, self.generation, config, self.auth
)
async def conflicts(self, session):
return automation_warnings(await self.automation(session), self.channel)
async def reachable(self, session):
try:
return await self.get_state(session) is not None
except Exception:
return False