Initial commit

This commit is contained in:
Sterling Archer
2026-08-10 21:37:46 -07:00
commit 56e75d9cde
25 changed files with 8285 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
__version__ = "1.0.0"
+620
View File
@@ -0,0 +1,620 @@
import asyncio
import time
from pathlib import Path
from aiohttp import ClientSession
from anker_solix_api.api import AnkerSolixApi
from anker_solix_api.mqtt_factory import SolixMqttDeviceFactory
from . import paths
from .credentials import load_credentials
from .profiles import classify, load_yaml, now_iso, save_yaml, slugify
MODEL_NAMES = {
"A1782": "SOLIX F3000",
"A1790": "SOLIX F3800",
"A1780": "SOLIX F2000",
"A1781": "SOLIX F2600",
"A1761": "SOLIX C1000X",
"A1753": "SOLIX C800",
"A17C1": "Solarbank 2",
"A17C5": "Solarbank 3",
"A17C0": "Solarbank E1600",
"A17X8": "Smart Plug",
"A2345": "Prime Charger",
"AS200": "Alternator Charger",
}
REALTIME_TIMEOUT = 60
POLLER_TIMEOUT = 60
IGNORED_KEYS = {"topics"}
ONLINE_KEYS = ("wifi_online", "is_online", "online", "wifi_connected")
BLUETOOTH_ONLY_HINT = """ This device reports as not cloud connected.
Some models (for example the F2000 / PowerHouse 767) pair with the Anker
app over Bluetooth only. You press a button on the unit to make it
discoverable, and it has no persistent cloud connection. Those devices
publish nothing to Anker's MQTT broker, which refuses the subscription.
Such a device cannot be used as an automation source here. Local
Bluetooth access needs a different project entirely (SolixBLE).
If you believe this device IS cloud connected, force an attempt with
--include-offline"""
OFFLINE_HINT = """ Check that the device is:
1. powered on and awake, not in standby
2. connected to WiFi, not only paired over Bluetooth
3. showing as online in the Anker app right now
This tool reads from Anker's cloud MQTT broker, so the device must be
reachable by the cloud. A Bluetooth-only connection is not enough."""
PROFILE_HEADER = """
Anker SOLIX device profile.
Generated by: solixauto discover-anker
This file is READ-ONLY input for the automation engine. The engine never
sends control commands to Anker devices; it only reads the fields below.
readable: fields observed in live MQTT telemetry, with the type and a
sample value captured at generation time.
derived: computed fields available to power-profile rules.
writable: control methods the library exposes for this model. Listed for
reference only. The automation engine does not call them.
Field names ending in ? or starting with unknown_ are not yet confirmed by
the upstream project. Avoid building rules on them.
Regenerate this file after a library update to pick up new fields.
"""
def model_label(part_number):
friendly = MODEL_NAMES.get(str(part_number).upper())
return f"{part_number} ({friendly})" if friendly else str(part_number)
def part_number_of(info):
return str((info or {}).get("device_pn") or (info or {}).get("product_code") or "")
def device_label(info):
return (
(info or {}).get("device_name")
or (info or {}).get("name")
or (info or {}).get("alias_name")
or (info or {}).get("alias")
or ""
)
def online_hint(info):
for key in ONLINE_KEYS:
if key in (info or {}):
value = info[key]
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("false", "0", "no", "offline"):
return False
if lowered in ("true", "1", "yes", "online"):
return True
continue
if value is None:
continue
return bool(value)
return None
def clean_status(raw):
return {key: value for key, value in (raw or {}).items() if key not in IGNORED_KEYS}
def build_derived(status):
derived = {}
pv_keys = sorted(k for k in status if k.startswith("pv_") and k.endswith("_power"))
if pv_keys:
derived["pv_total"] = {
"expression": " + ".join(pv_keys),
"description": "collective solar input in watts",
}
usb_keys = sorted(
k
for k in status
if (k.startswith("usbc_") or k.startswith("usba_")) and k.endswith("_power")
)
if usb_keys:
derived["usb_total"] = {
"expression": " + ".join(usb_keys),
"description": "combined USB output in watts",
}
if pv_keys and "output_power_total" in status:
derived["pv_surplus"] = {
"expression": " + ".join(pv_keys) + " - output_power_total",
"description": (
"solar minus load in watts. Positive means the sun is covering "
"everything drawing from the unit and the surplus charges the "
"battery. Negative means the battery is making up the shortfall."
),
}
derived["pv_covers_load"] = {
"expression": " + ".join(pv_keys) + " >= output_power_total",
"description": "true when solar alone is carrying the load",
}
if "ac_input_power" in status and "output_power_total" in status:
derived["net_power"] = {
"expression": "ac_input_power - output_power_total",
"description": "positive when importing, negative when discharging",
}
if "battery_soc" in status and "min_soc" in status:
derived["soc_headroom"] = {
"expression": "battery_soc - min_soc",
"description": "percentage points above the configured floor",
}
return derived
def introspect_writable(device):
if device is None:
return {}
writable = {}
for name in sorted(dir(device)):
if not name.startswith("set_"):
continue
attribute = getattr(device, name, None)
if not callable(attribute):
continue
entry = {"method": name}
try:
import inspect
signature = inspect.signature(attribute)
params = [
p for p in signature.parameters if p not in ("self", "args", "kwargs")
]
if params:
entry["parameters"] = params
except (TypeError, ValueError):
pass
writable[name[4:]] = entry
commands = getattr(device, "commands", None)
if isinstance(commands, dict):
for name in sorted(commands):
writable.setdefault(str(name), {"command": str(name)})
return writable
def build_profile(device_sn, info, status, device):
part_number = part_number_of(info) or "unknown"
readable = {}
for key in sorted(status):
value = status[key]
readable[key] = {"type": classify(value), "sample": value}
name = device_label(info)
aliases = [entry for entry in (name, device_sn, part_number) if entry]
return {
"kind": "anker",
"generated": now_iso(),
"aliases": aliases,
"identity": {
"serial": device_sn,
"part_number": part_number,
"model": MODEL_NAMES.get(part_number.upper(), "unknown"),
"name": name,
"make": "Anker SOLIX",
},
"access": {
"transport": "anker-cloud-mqtt",
"engine_mode": "read-only",
"realtime_trigger_timeout_seconds": REALTIME_TIMEOUT,
},
"derived": build_derived(status),
"readable": readable,
"writable": introspect_writable(device),
}
class MqttReader:
def __init__(self, api, session, device_sn, info):
self.api = api
self.session = session
self.device_sn = device_sn
self.info = info
self.device = None
self.topics = set()
self.poller = None
self.last_message = 0.0
self.message_count = 0
def _callback(self, session, topic, message, data, model, *args, **kwargs):
self.last_message = time.monotonic()
self.message_count += 1
def build_topics(self):
topics = set()
prefix = self.session.get_topic_prefix(deviceDict=self.info)
if prefix:
topics.add(f"{prefix}#")
command_prefix = self.session.get_topic_prefix(
deviceDict=self.info, publish=True
)
if command_prefix:
topics.add(f"{command_prefix}#")
return topics
def prepare(self):
self.topics = self.build_topics()
if not self.topics:
raise RuntimeError(
f"could not resolve an MQTT topic prefix for {self.device_sn}. "
"The device may not be owned by this account."
)
self.device = SolixMqttDeviceFactory(self.api, self.device_sn).create_device()
return self.topics
async def start(self, realtime=True):
self.prepare()
trigger_devices = {self.device_sn} if realtime else set()
self.poller = asyncio.get_running_loop().create_task(
self.session.message_poller(
topics=self.topics,
trigger_devices=trigger_devices,
msg_callback=self._callback,
timeout=POLLER_TIMEOUT,
)
)
await asyncio.sleep(2)
self.request_status()
def request_status(self):
try:
result = self.session.status_request(deviceDict=self.info, wait_for_publish=2)
return bool(result and result.is_published())
except Exception:
return False
def read(self):
data = getattr(self.session, "mqtt_data", None) or {}
return clean_status(data.get(self.device_sn) or {})
async def wait_for_data(self, seconds=45, verbose=False, required=None):
deadline = time.monotonic() + seconds
requested_again = False
required = set(required or ())
while time.monotonic() < deadline:
status = self.read()
if status and not (required - set(status)):
return status
if self.poller and self.poller.done():
error = self.poller.exception()
if error:
raise RuntimeError(f"MQTT poller stopped: {error}")
remaining = deadline - time.monotonic()
if not requested_again and remaining < seconds / 2:
requested_again = True
self.request_status()
if verbose:
print(" no data yet, re-requesting status...")
await asyncio.sleep(1)
return self.read()
async def settle(self, seconds=8):
best = self.read()
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
await asyncio.sleep(2)
latest = self.read()
if len(latest) > len(best):
best = latest
return best
def age_seconds(self):
if not self.last_message:
return None
return time.monotonic() - self.last_message
async def stop(self):
if self.poller is not None:
self.poller.cancel()
try:
await self.poller
except asyncio.CancelledError:
pass
except Exception:
pass
self.poller = None
async def open_session(verbose=True):
user, password, country = load_credentials()
websession = ClientSession()
api = AnkerSolixApi(user, password, country, websession, None)
try:
if await api.async_authenticate():
if verbose:
print("Anker cloud authentication: OK")
elif verbose:
print("Anker cloud authentication: cached token")
await api.update_sites()
await api.get_bind_devices()
mqtt_session = await api.startMqttSession()
if not mqtt_session:
raise RuntimeError("startMqttSession returned nothing")
if not mqtt_session.is_connected():
raise RuntimeError("MQTT session did not connect")
if verbose:
print(f"Connected to MQTT server {mqtt_session.host}:{mqtt_session.port}")
return api, websession, mqtt_session
except Exception:
await websession.close()
raise
async def close_session(api, websession):
session = getattr(api, "mqttsession", None)
if session is not None:
cleanup = getattr(session, "cleanup", None)
if callable(cleanup):
try:
cleanup()
except Exception:
pass
await websession.close()
async def discover(
settle=45, only_pn=None, only_sn=None, skip=None, include_offline=False, verbose=True
):
paths.ensure_dirs()
if verbose:
print("Authenticating and enumerating devices...")
api, websession, mqtt_session = await open_session(verbose=verbose)
written = []
try:
devices = api.devices or {}
if not devices:
raise RuntimeError("no owned devices returned for this account")
skip = {s.strip() for s in (skip or []) if s.strip()}
targets = {}
for serial, info in devices.items():
if only_sn and serial != only_sn:
continue
if only_pn and part_number_of(info).upper() != only_pn.upper():
continue
if serial in skip:
if verbose:
print(f" skipping {serial} (--skip)")
continue
if not include_offline and online_hint(info) is False:
print()
print(f" {serial} - {model_label(part_number_of(info))}")
print(" not cloud connected, skipping without subscribing.")
print(BLUETOOTH_ONLY_HINT)
continue
targets[serial] = info
if not targets:
raise RuntimeError("no devices matched, or all matches were offline")
if verbose:
print(f"Found {len(targets)} device(s) to harvest.")
readers = {}
all_topics = set()
for serial, info in targets.items():
reader = MqttReader(api, mqtt_session, serial, info)
try:
all_topics |= reader.prepare()
readers[serial] = reader
except Exception as err:
print(f" {serial}: {err}")
if not readers:
raise RuntimeError("no device topics could be resolved")
def shared_callback(session, topic, message, data, model, *args, **kwargs):
for entry in readers.values():
if topic and entry.topics:
for pattern in entry.topics:
if topic.startswith(pattern.rstrip("#")):
entry._callback(
session, topic, message, data, model, *args, **kwargs
)
return
if verbose:
print(
f"Subscribing {len(all_topics)} topic(s) for "
f"{len(readers)} device(s) on one session..."
)
poller = asyncio.get_running_loop().create_task(
mqtt_session.message_poller(
topics=all_topics,
trigger_devices=set(readers),
msg_callback=shared_callback,
timeout=POLLER_TIMEOUT,
)
)
try:
await asyncio.sleep(3)
for reader in readers.values():
reader.request_status()
for serial, reader in readers.items():
info = targets[serial]
label = model_label(part_number_of(info))
if verbose:
print()
print(f" {serial} - {label}")
print(f" waiting up to {settle}s for telemetry...")
status = await reader.wait_for_data(settle, verbose=verbose)
if not status:
print(
f" no telemetry decoded after {settle}s "
f"({reader.message_count} message(s) seen)"
)
if reader.message_count:
print(
" messages arrived but decoded to nothing. This model "
"may not have field mappings in mqttmap.py yet."
)
else:
print(" no messages received from this device.")
print(OFFLINE_HINT)
print()
print(
" Once it is online, retry just this device with:"
)
print(f" {paths.command('discover-anker --sn ' + serial)}")
continue
status = await reader.settle(8)
profile = build_profile(serial, info, status, reader.device)
friendly = profile["identity"].get("name") or ""
if friendly:
stem = slugify(friendly).lower()
else:
stem = (
f"{slugify(part_number_of(info) or 'device')}-"
f"{slugify(serial)}"
).lower()
destination = paths.ANKER_PROFILE_DIR / f"{stem}.yaml"
save_yaml(destination, profile, header=PROFILE_HEADER)
written.append(destination)
if verbose:
print(
f" {len(profile['readable'])} readable, "
f"{len(profile['derived'])} derived, "
f"{len(profile['writable'])} control method(s)"
)
print(f" wrote {paths.relative(destination)}")
finally:
poller.cancel()
try:
await poller
except asyncio.CancelledError:
pass
except Exception:
pass
finally:
await close_session(api, websession)
return written
class AnkerSource:
def __init__(self, profile_path):
self.profile_path = Path(profile_path)
self.profile = load_yaml(self.profile_path)
identity = self.profile.get("identity", {})
self.serial = identity.get("serial")
self.part_number = identity.get("part_number")
self.label = model_label(self.part_number)
self.derived = self.profile.get("derived", {}) or {}
if not self.serial:
raise ValueError(f"{self.profile_path} has no identity.serial")
self._api = None
self._websession = None
self._mqtt = None
self._reader = None
async def start(self, settle=45, required=None):
self._api, self._websession, self._mqtt = await open_session(verbose=False)
info = (self._api.devices or {}).get(self.serial)
if info is None:
raise RuntimeError(f"serial {self.serial} is not owned by this account")
self._reader = MqttReader(self._api, self._mqtt, self.serial, info)
await self._reader.start(realtime=True)
status = await self._reader.wait_for_data(settle, required=required)
if not status:
raise RuntimeError(
f"no telemetry from {self.label} {self.serial} after {settle}s.\n"
+ OFFLINE_HINT
)
missing = set(required or ()) - set(status)
if missing:
raise RuntimeError(
f"telemetry from {self.serial} is missing field(s) "
f"{sorted(missing)} after {settle}s. Check the field names in your "
"power profile against: solixauto fields <device>"
)
def read(self):
if self._reader is None:
return {}
return self._reader.read()
def age_seconds(self):
if self._reader is None:
return None
return self._reader.age_seconds()
def connected(self):
if self._mqtt is None:
return False
try:
return bool(self._mqtt.is_connected())
except Exception:
return False
async def trigger(self):
if self._reader is None:
return False
return self._reader.request_status()
async def stop(self):
if self._reader is not None:
await self._reader.stop()
self._reader = None
if self._api is not None and self._websession is not None:
await close_session(self._api, self._websession)
self._api = None
self._websession = None
+1510
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
import os
from pathlib import Path
from . import paths
def load_credentials():
candidates = []
explicit = os.environ.get("SOLIXAUTO_ENV")
if explicit:
candidates.append(Path(explicit).expanduser())
candidates.append(Path.cwd() / ".env")
candidates.append(paths.BASE_DIR / ".env")
candidates.append(Path.home() / "anker-solix-mqtt" / "anker-solix-api" / ".env")
candidates.append(Path.home() / "anker-solix-api" / ".env")
for candidate in candidates:
if candidate.exists():
_load_env_file(candidate)
break
user = os.environ.get("ANKERUSER")
password = os.environ.get("ANKERPASSWORD")
country = os.environ.get("ANKERCOUNTRY", "US")
if not user or not password:
raise RuntimeError(
"ANKERUSER / ANKERPASSWORD not found. Set them in the environment, "
"or place a .env file in the current directory, in "
f"{paths.BASE_DIR}, or point SOLIXAUTO_ENV at one."
)
return user, password, country
def _load_env_file(path):
try:
from dotenv import load_dotenv
load_dotenv(path)
return
except ImportError:
pass
for line in Path(path).read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
os.environ.setdefault(key.strip(), value)
+853
View File
@@ -0,0 +1,853 @@
import asyncio
import json
import sys
import time
from collections import deque
from datetime import datetime
import aiohttp
from . import paths
from .profiles import load_yaml
from .notify import Notifier, render
from .rules import derived_values, format_duration, validate
from .shelly import ShellyTarget
async def interruptible_sleep(seconds):
remaining = float(seconds or 0)
while remaining > 0:
chunk = min(0.5, remaining)
await asyncio.sleep(chunk)
remaining -= chunk
def configure_event_loop():
if sys.platform.startswith("win"):
policy = getattr(asyncio, "WindowsSelectorEventLoopPolicy", None)
if policy is not None:
asyncio.set_event_loop_policy(policy())
def stamp():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
class Reporter:
def __init__(self, log_path=None, quiet=False):
self.quiet = quiet
self.handle = None
if log_path:
log_path.parent.mkdir(parents=True, exist_ok=True)
self.handle = open(log_path, "a", encoding="utf-8")
def __call__(self, message, force=False):
line = f"[{stamp()}] {message}"
if not self.quiet or force:
print(line, flush=True)
if self.handle:
self.handle.write(line + "\n")
self.handle.flush()
def close(self):
if self.handle:
self.handle.close()
self.handle = None
class RuleState:
def __init__(self, rule):
self.rule = rule
self.satisfied_since = None
self.last_value = None
self.error = None
def update(self, variables, now):
try:
satisfied = self.rule.evaluate(variables)
self.error = None
except Exception as err:
self.error = f"{type(err).__name__}: {err}"
satisfied = False
self.last_value = satisfied
if satisfied:
if self.satisfied_since is None:
self.satisfied_since = now
else:
self.satisfied_since = None
return satisfied
def held_for(self, now):
if self.satisfied_since is None:
return 0.0
return now - self.satisfied_since
def ripe(self, now):
if not self.last_value:
return False
dwell = self.rule.dwell or 0
return self.held_for(now) >= dwell
class Engine:
def __init__(self, profile, dry_run=False, reporter=None):
self.profile = profile
self.dry_run = dry_run
self.report = reporter or Reporter()
from .anker import AnkerSource
self.anker_profile = load_yaml(profile.source_path)
self.source = AnkerSource(profile.source_path)
self.target = ShellyTarget(profile.target_path, profile.target_channel)
self.notifier = Notifier(
profile.notifications, reporter=self.report, dry_run=dry_run
)
self.states = [RuleState(rule) for rule in profile.active_rules()]
self.recent_actions = deque()
self.last_action_at = 0.0
self.last_commanded = None
self.stale_reported = False
self.floor_latched = False
self.floor_since = None
self.last_heartbeat = 0.0
self.heartbeat_every = 300
self.incomplete_reported = False
def evaluate(self, variables, now):
for state in self.states:
state.update(variables, now)
ripe = [state for state in self.states if state.ripe(now)]
if not ripe:
return None, []
ripe.sort(key=lambda s: (-s.rule.priority, self.states.index(s)))
return ripe[0], ripe
def rate_limited(self, now):
while self.recent_actions and now - self.recent_actions[0] > 3600:
self.recent_actions.popleft()
if self.last_action_at and (now - self.last_action_at) < (self.profile.min_gap or 0):
remaining = (self.profile.min_gap or 0) - (now - self.last_action_at)
return f"min gap, {format_duration(remaining)} remaining"
if len(self.recent_actions) >= self.profile.max_per_hour:
return f"hourly cap of {self.profile.max_per_hour} actions reached"
return None
async def apply(
self, session, desired, reason, now, rule=None, variables=None, force=False
):
if self.last_commanded is desired:
return False
blocked = None if force else self.rate_limited(now)
if blocked:
self.report(f"suppressed {self._word(desired)} ({reason}): {blocked}")
return False
if self.dry_run:
self.report(f"DRY RUN would turn {self._word(desired)} - {reason}")
self.last_commanded = desired
return True
try:
await self.target.set_state(session, desired)
except Exception as err:
self.report(f"FAILED to turn {self._word(desired)}: {type(err).__name__}: {err}")
return False
self.last_commanded = desired
self.last_action_at = now
self.recent_actions.append(now)
self.report(f"turned {self._word(desired)} - {reason}")
self.save_state(desired, reason)
await self.notify(desired, rule, variables, event="action")
return True
@staticmethod
def _word(desired):
return "ON" if desired else "OFF"
def context(self, desired, rule, variables, event="action"):
source_identity = self.anker_profile.get("identity", {})
target_identity = self.target.profile.get("identity", {})
context = dict(variables or {})
context.update(
{
"profile": self.profile.name,
"event": event,
"rule": rule.name if rule else "",
"condition": rule.when_source if rule else "",
"action": self._word(desired) if desired is not None else "",
"action_word": ("on" if desired else "off") if desired is not None else "",
"source_name": (
source_identity.get("name")
or source_identity.get("model")
or source_identity.get("serial")
),
"source_model": source_identity.get("model", ""),
"source_serial": source_identity.get("serial", ""),
"target_name": (
target_identity.get("name")
or target_identity.get("model")
or self.target.host
),
"target_model": target_identity.get("model", ""),
"target_host": self.target.host,
"target_channel": self.target.channel,
"time": stamp(),
}
)
return context
async def notify(self, desired, rule, variables, event="action"):
settings = self.profile.notifications
if not settings.wants(event):
return
if event == "action" and not settings.rule_enabled(rule):
return
context = self.context(desired, rule, variables, event)
body = render(settings.template_for(rule), context)
title = render(settings.title, context)
priority = (rule.notify_priority if rule else None) or settings.priority
key = rule.name if rule else event
await self.notifier.send(title, body, priority=priority, key=key)
def required_fields(self):
names = set(self.profile.referenced_names())
floor = self.profile.battery_floor
if floor is not None and floor.enabled:
names.add(floor.field)
return names
def summarize(self, variables, now):
names = sorted(self.profile.referenced_names())
floor = self.profile.battery_floor
if floor and floor.field not in names:
names.insert(0, floor.field)
readings = " ".join(
f"{name}={variables.get(name)}" for name in names if name in variables
)
parts = []
for state in self.states:
if state.error:
parts.append(f"{state.rule.name}=ERROR")
continue
if not state.last_value:
continue
dwell = state.rule.dwell or 0
held = state.held_for(now)
if state.ripe(now):
parts.append(f"{state.rule.name}=READY")
else:
parts.append(
f"{state.rule.name}={format_duration(held)}/{format_duration(dwell)}"
)
floor = self.profile.battery_floor
if self.floor_latched:
status = f"FLOOR LATCHED until {floor.field} >= {floor.release:g}"
elif floor is not None and self.floor_since is not None:
held = now - self.floor_since
status = (
f"FLOOR ARMING {format_duration(held)}/"
f"{format_duration(floor.dwell)}"
)
elif parts:
status = "; ".join(parts)
else:
status = "no rule matches"
target = "?" if self.last_commanded is None else self._word(self.last_commanded)
return f"{readings} | target={target} | {status}"
def heartbeat(self, variables, now, force=False):
due = force or self.dry_run or (now - self.last_heartbeat) >= self.heartbeat_every
if not due:
return
self.last_heartbeat = now
self.report(self.summarize(variables, now))
async def check_floor(self, session, variables, now):
floor = self.profile.battery_floor
if floor is None or not floor.enabled:
return False
value = variables.get(floor.field)
if not isinstance(value, (int, float)) or isinstance(value, bool):
if self.floor_latched:
self.report(
f"battery floor: {floor.field} is unreadable, holding the latch",
force=True,
)
return True
return False
if self.floor_latched:
if value >= floor.release:
self.floor_latched = False
self.floor_since = None
self.report(
f"battery floor released, {floor.field} back to {value:g}",
force=True,
)
if floor.notify_release:
await self.notify_floor(variables, value, released=True)
return False
await self.apply(
session,
floor.desired_state(),
f"battery floor holding, {floor.field}={value:g}",
now,
variables=variables,
force=True,
)
return True
if value > floor.threshold:
self.floor_since = None
return False
if self.floor_since is None:
self.floor_since = now
if (now - self.floor_since) < (floor.dwell or 0):
return True
self.floor_latched = True
self.report(
f"BATTERY FLOOR TRIPPED: {floor.field}={value:g} at or below "
f"{floor.threshold:g}",
force=True,
)
await self.apply(
session,
floor.desired_state(),
f"battery floor, {floor.field}={value:g}",
now,
variables=variables,
force=True,
)
if floor.notify:
await self.notify_floor(variables, value)
return True
async def notify_floor(self, variables, value, released=False):
floor = self.profile.battery_floor
if floor is None:
return
if not self.notifier.available():
if not released:
self.report(
"battery floor tripped but no notification channel is enabled",
force=True,
)
return
settings = self.profile.notifications
desired = None if released else floor.desired_state()
context = self.context(desired, None, variables, "safety")
context["value"] = f"{value:g}"
context["field"] = floor.field
context["threshold"] = f"{floor.threshold:g}"
context["release"] = f"{floor.release:g}"
context["reason"] = f"{floor.field} at {value:g}"
template = floor.release_template if released else floor.notify_template
body = render(template, context)
title = render(settings.title or "{profile}", context)
await self.notifier.send(
title,
body,
priority="urgent" if not released else None,
key="battery_floor_release" if released else "battery_floor",
force=True,
)
def save_state(self, desired, reason):
record = {
"profile": self.profile.name,
"updated": stamp(),
"target_state": bool(desired),
"reason": reason,
"actions_last_hour": len(self.recent_actions),
}
try:
paths.STATE_DIR.mkdir(parents=True, exist_ok=True)
existing = {}
if paths.RUNTIME_STATE.exists():
existing = json.loads(paths.RUNTIME_STATE.read_text(encoding="utf-8"))
existing[self.profile.name] = record
paths.RUNTIME_STATE.write_text(
json.dumps(existing, indent=2), encoding="utf-8"
)
except Exception:
pass
async def tick(self, session):
now = time.monotonic()
status = self.source.read()
age = self.source.age_seconds()
if not status:
self.report("no telemetry yet")
return
if not self.source.connected():
if not self.stale_reported:
self.report("MQTT session disconnected", force=True)
self.stale_reported = True
await self.notify(
None, None, {"reason": "mqtt disconnected"}, event="stale"
)
if self.profile.on_stale == "stop":
raise RuntimeError("stopping: MQTT session disconnected")
if self.profile.on_stale == "safe_state":
await self.apply(
session, self.profile.safe_state, "mqtt disconnected safe state", now
)
return
if age is not None and self.profile.stale_after and age > self.profile.stale_after:
if not self.stale_reported:
self.report(
f"telemetry stale ({format_duration(age)} old), "
f"policy={self.profile.on_stale}",
force=True,
)
self.stale_reported = True
await self.notify(None, None, {"reason": "telemetry stale"}, event="stale")
if self.profile.on_stale == "stop":
raise RuntimeError("stopping: telemetry went stale")
if self.profile.on_stale == "safe_state":
await self.apply(
session, self.profile.safe_state, "stale telemetry safe state", now
)
return
if self.stale_reported:
self.report("telemetry recovered", force=True)
self.stale_reported = False
variables = derived_values(self.anker_profile, status)
missing = sorted(
name
for name in self.required_fields()
if name not in variables or variables[name] is None
)
if missing:
if not self.incomplete_reported:
self.report(
f"waiting for telemetry field(s) {missing}. Not evaluating any "
"rule or the safety floor until they arrive.",
force=True,
)
self.incomplete_reported = True
return
if self.incomplete_reported:
self.report("telemetry complete, resuming evaluation", force=True)
self.incomplete_reported = False
if await self.check_floor(session, variables, now):
self.heartbeat(variables, now)
return
winner, ripe = self.evaluate(variables, now)
for state in self.states:
if state.error:
self.report(f"rule {state.rule.name!r} error: {state.error}")
self.heartbeat(variables, now)
if winner is None:
return
desired = winner.rule.desired_state()
if desired is None:
return
reason = f"{winner.rule.name} [{winner.rule.when_source}]"
if len(ripe) > 1:
reason += f" (priority over {len(ripe) - 1} other)"
await self.apply(session, desired, reason, now, rule=winner.rule, variables=variables)
async def run(self, cycles=None):
await self.source.start(required=self.required_fields())
self.report(f"source: {self.source.label} {self.source.serial}", force=True)
if self.profile.battery_floor:
self.report(
f"battery floor: {self.profile.battery_floor.describe()}", force=True
)
else:
self.report("battery floor: NONE SET", force=True)
self.report(f"target: {self.target.label} channel {self.target.channel}", force=True)
async with aiohttp.ClientSession() as session:
current = await self.target.get_state(session)
if current is None:
self.report("warning: could not read the Shelly current state", force=True)
else:
self.last_commanded = bool(current)
self.report(f"target currently {self._word(current)}", force=True)
try:
conflicts = await self.target.conflicts(session)
except Exception:
conflicts = []
if conflicts:
self.report("=" * 60, force=True)
self.report(
f"{len(conflicts)} CONFLICTING AUTOMATION(S) ON THE SHELLY ITSELF",
force=True,
)
for item in conflicts:
self.report(f" {item}", force=True)
self.report(
"These run on the device and will fight these rules. "
"Remove them in the Shelly app before relying on this.",
force=True,
)
self.report("=" * 60, force=True)
if self.dry_run:
self.report(
"DRY RUN: evaluating normally, but no switch command will be "
"sent and no notification will fire",
force=True,
)
count = 0
try:
while cycles is None or count < cycles:
await self.tick(session)
count += 1
if cycles is not None and count >= cycles:
self.report(
f"completed {count} cycle(s), stopping as requested",
force=True,
)
break
await interruptible_sleep(self.profile.poll_interval)
finally:
await self.source.stop()
await asyncio.sleep(0.25)
async def close(self):
await self.source.stop()
async def dry_run_report(profile, cycles=3, offline=False, overrides=None):
problems, notes = validate(profile)
print()
print(f"Power profile: {profile.name}")
print(f" file {paths.relative(profile.path)}")
print(f" source {profile.source_reference}")
print(f" target {profile.target_reference}")
print(f" enabled {profile.enabled}")
print()
for note in notes:
print(f" note: {note}")
for problem in problems:
print(f" PROBLEM: {problem}")
if problems:
print()
print(f"{len(problems)} problem(s) found. Fix these before running.")
return False
print(f" syntax OK, {len(profile.active_rules())} active rule(s)")
settings = profile.notifications
if settings.enabled:
from .notify import Notifier
probe = Notifier(settings)
channels = probe.available()
missing = probe.missing()
print(
f" notifications on via {', '.join(channels) if channels else 'NO CHANNEL'}"
f", throttle {format_duration(settings.throttle)}"
)
if missing:
print(f" requested but not enabled: {', '.join(missing)}")
if not channels:
print(" nothing will be delivered until a channel is enabled")
else:
print(" notifications off")
if profile.battery_floor:
floor = profile.battery_floor
print(f" safety floor: {floor.describe()}, dwell {format_duration(floor.dwell)}")
else:
print(" safety floor: NONE SET")
if overrides:
print()
print("Simulated overrides:")
for key, value in sorted(overrides.items()):
print(f" {key} = {value!r}")
if offline:
anker_profile = load_yaml(profile.source_path)
samples = {
key: spec.get("sample")
for key, spec in (anker_profile.get("readable") or {}).items()
}
variables = derived_values(anker_profile, samples)
if overrides:
variables.update(overrides)
print()
print("Offline evaluation against the sample values in the device profile:")
referenced = sorted(profile.referenced_names())
overridden = overrides or {}
if referenced:
readings = ", ".join(
f"{name}={variables.get(name)!r}"
+ (" (simulated)" if name in overridden else "")
for name in referenced
)
print(f" values: {readings}")
floor_wins = _print_floor_verdict(profile, variables)
print()
if floor_wins:
print(" Rules below are shown for reference, but the floor would")
print(" take precedence while it is latched:")
_print_rule_table(profile, variables, overrides=overrides, skip_values=True)
print()
print("Sample values are a snapshot from discovery, not live data.")
return True
print()
print(f"Connecting for a live dry run ({cycles} cycle(s), no commands sent)...")
engine = Engine(profile, dry_run=True)
await engine.source.start(required=engine.required_fields())
try:
async with aiohttp.ClientSession() as session:
reachable = await engine.target.reachable(session)
current = await engine.target.get_state(session)
print()
print(f" Shelly reachable: {reachable}")
if current is not None:
print(f" Shelly currently: {'ON' if current else 'OFF'}")
if not reachable:
print(
" the target did not respond; check access.host in its profile"
)
for index in range(cycles):
if index:
await interruptible_sleep(profile.poll_interval)
status = engine.source.read()
if not status:
print()
print(f" cycle {index + 1}: no telemetry decoded yet")
continue
variables = derived_values(engine.anker_profile, status)
if overrides:
variables.update(overrides)
absent = sorted(
name
for name in engine.required_fields()
if name not in variables or variables[name] is None
)
if absent:
print()
print(f" cycle {index + 1}: waiting for field(s) {absent}")
print(" nothing is evaluated until they arrive")
continue
now = time.monotonic()
for state in engine.states:
state.update(variables, now)
print()
print(f" cycle {index + 1} (telemetry age "
f"{format_duration(engine.source.age_seconds())})")
floor_wins = _print_floor_verdict(profile, variables)
print()
if floor_wins:
print(" Rules below are shown for reference, but the floor")
print(" would take precedence while it is latched:")
_print_rule_table(
profile, variables, engine, now, overrides=overrides
)
finally:
await engine.source.stop()
print()
print("Dry run complete. No commands were sent.")
return True
def _preview_notification(profile, rule, variables, desired):
from .notify import render
settings = profile.notifications
if not settings.wants("action") or not settings.rule_enabled(rule):
return None
source_identity = load_yaml(profile.source_path).get("identity", {})
target_profile = load_yaml(profile.target_path)
target_identity = target_profile.get("identity", {})
context = dict(variables)
context.update(
{
"profile": profile.name,
"rule": rule.name,
"condition": rule.when_source,
"action": "ON" if desired else "OFF",
"action_word": "on" if desired else "off",
"source_name": (
source_identity.get("name")
or source_identity.get("model")
or source_identity.get("serial")
),
"source_model": source_identity.get("model", ""),
"source_serial": source_identity.get("serial", ""),
"target_name": (
target_identity.get("name")
or target_identity.get("model")
or target_profile.get("access", {}).get("host")
),
"target_model": target_identity.get("model", ""),
"target_host": target_profile.get("access", {}).get("host", ""),
"target_channel": profile.target_channel or 0,
"time": stamp(),
"event": "action",
}
)
return render(settings.template_for(rule), context)
def _print_floor_verdict(profile, variables):
floor = profile.battery_floor
if floor is None or not floor.enabled:
return False
value = variables.get(floor.field)
print()
if not isinstance(value, (int, float)) or isinstance(value, bool):
print(f" SAFETY FLOOR: {floor.field} is not readable, cannot evaluate")
return False
if value <= floor.threshold:
print(
f" SAFETY FLOOR TRIPS: {floor.field}={value:g} is at or below "
f"{floor.threshold:g}"
)
print(
f" after {format_duration(floor.dwell)} it would {floor.action} "
f"and LATCH until {floor.field} reaches {floor.release:g}"
)
print(" it bypasses the rate limits and outranks every rule below")
print(" while latched, no rule can turn the target off")
return True
print(
f" safety floor idle: {floor.field}={value:g} is above "
f"{floor.threshold:g}"
)
return False
def _print_rule_table(
profile, variables, engine=None, now=None, overrides=None, skip_values=False
):
referenced = sorted(profile.referenced_names())
overrides = overrides or {}
if referenced and not skip_values:
readings = ", ".join(
f"{name}={variables.get(name)!r}"
+ (" (simulated)" if name in overrides else "")
for name in referenced
)
print(f" values: {readings}")
states = engine.states if engine else None
for index, rule in enumerate(profile.active_rules()):
if states:
state = states[index]
satisfied = state.last_value
held = state.held_for(now) if now else 0
ripe = state.ripe(now) if now else False
if state.error:
verdict = f"ERROR {state.error}"
elif not satisfied:
verdict = "false"
elif ripe:
verdict = f"TRUE and ripe -> would {rule.action}"
else:
remaining = (rule.dwell or 0) - held
verdict = f"true, waiting {format_duration(remaining)} of dwell"
else:
try:
satisfied = rule.evaluate(variables)
verdict = (
f"true -> would {rule.action} after {format_duration(rule.dwell)}"
if satisfied
else "false"
)
except Exception as err:
verdict = f"ERROR {type(err).__name__}: {err}"
print(f" [{rule.priority:>3}] {rule.name}")
print(f" when {rule.when_source}")
print(f" {verdict}")
desired = rule.desired_state()
if desired is not None:
if engine:
message = _render_live_notification(engine, rule, variables, desired)
else:
message = _preview_notification(profile, rule, variables, desired)
if message:
print(f" notify: {message}")
def _render_live_notification(engine, rule, variables, desired):
from .notify import render
settings = engine.profile.notifications
if not settings.wants("action") or not settings.rule_enabled(rule):
return None
context = engine.context(desired, rule, variables, "action")
return render(settings.template_for(rule), context)
+654
View File
@@ -0,0 +1,654 @@
import asyncio
import json
import secrets
import shutil
import smtplib
import sys
import string
import subprocess
import time
from email.message import EmailMessage
from pathlib import Path
import aiohttp
from . import paths
from .profiles import load_yaml, write_text
CONFIG_PATH = paths.BASE_DIR / "notifications.yaml"
SEND_TIMEOUT = 15
CONFIG_TEMPLATE = """# Notification channels for solixauto.
#
# Secrets live here, NOT in your power profiles, so profiles stay safe to
# share or commit. This file is created with owner-only permissions.
#
# Enable a channel by setting enabled: true and filling in its settings.
# Test with:
# solixauto notify-test
# solixauto notify-test --channel ntfy
#
# ---------------------------------------------------------------------
# ntfy - recommended. Free, no account needed.
# 1. install the ntfy app on your phone
# 2. subscribe to a topic name that nobody else would guess
# 3. put that topic below
# Anyone who knows the topic name can read your alerts, so make it long.
# ---------------------------------------------------------------------
ntfy:
enabled: false
server: https://ntfy.sh
topic: solix-CHANGE-ME-to-something-random
priority: default
token: ""
# ---------------------------------------------------------------------
# Pushover - $5 one time per platform. Very reliable delivery.
# Get both keys from https://pushover.net
# ---------------------------------------------------------------------
pushover:
enabled: false
user_key: ""
api_token: ""
priority: 0
sound: ""
# ---------------------------------------------------------------------
# Email over SMTP.
# For Gmail you must use an App Password, not your normal password.
# ---------------------------------------------------------------------
email:
enabled: false
host: smtp.gmail.com
port: 587
use_tls: true
username: ""
password: ""
sender: ""
recipients: []
# ---------------------------------------------------------------------
# Telegram - free. Create a bot with @BotFather, then message it once and
# read your chat id from https://api.telegram.org/bot<TOKEN>/getUpdates
# ---------------------------------------------------------------------
telegram:
enabled: false
bot_token: ""
chat_id: ""
# ---------------------------------------------------------------------
# Generic webhook. Works with Slack and Discord incoming webhooks.
# format: slack | discord | json | form
# ---------------------------------------------------------------------
webhook:
enabled: false
url: ""
format: json
method: POST
# ---------------------------------------------------------------------
# Desktop notification on the machine running the engine.
# Useful while testing. Does not reach your phone.
#
# macOS uses osascript, built in
# Linux uses notify-send, from libnotify-bin
# Windows uses PowerShell, built in
#
# sound is macOS only and ignored elsewhere.
# ---------------------------------------------------------------------
desktop:
enabled: false
sound: Submarine
"""
class SafeDict(dict):
def __missing__(self, key):
return "?"
class TemplateFormatter(string.Formatter):
def get_value(self, key, args, kwargs):
if isinstance(key, str):
return kwargs.get(key, "?")
return "?"
def format_field(self, value, format_spec):
try:
return super().format_field(value, format_spec)
except (TypeError, ValueError):
return str(value)
FORMATTER = TemplateFormatter()
def render(template, context):
try:
return FORMATTER.vformat(str(template), (), SafeDict(context))
except Exception:
return str(template)
def template_fields(template):
found = set()
try:
for _, field, _, _ in string.Formatter().parse(str(template)):
if field:
found.add(field.split(".")[0].split("[")[0])
except ValueError:
pass
return found
def ensure_config():
paths.ensure_dirs()
if not CONFIG_PATH.exists():
write_text(CONFIG_PATH, CONFIG_TEMPLATE)
paths.secure_file(CONFIG_PATH)
return CONFIG_PATH
def set_value(text, channel, key, value):
lines = text.splitlines()
output = []
inside = False
replaced = False
if isinstance(value, bool):
rendered = "true" if value else "false"
elif isinstance(value, list):
rendered = "[" + ", ".join(str(v) for v in value) + "]"
elif value == "":
rendered = '""'
else:
rendered = str(value)
for line in lines:
stripped = line.strip()
if not line.startswith((" ", "\t")) and stripped.endswith(":"):
if inside and not replaced:
pass
inside = stripped[:-1] == channel
if inside and not replaced:
without_indent = line.lstrip()
if without_indent.startswith(f"{key}:"):
indent = line[: len(line) - len(without_indent)]
output.append(f"{indent}{key}: {rendered}")
replaced = True
continue
output.append(line)
return "\n".join(output) + "\n", replaced
def apply_settings(channel, values):
ensure_config()
text = CONFIG_PATH.read_text(encoding="utf-8")
missing = []
for key, value in values.items():
text, replaced = set_value(text, channel, key, value)
if not replaced:
missing.append(key)
CONFIG_PATH.write_text(text, encoding="utf-8")
paths.secure_file(CONFIG_PATH)
return missing
def generate_topic(prefix="solix"):
return f"{prefix}-{secrets.token_hex(10)}"
NTFY_IOS_URL = "https://apps.apple.com/us/app/ntfy/id1625396347"
NTFY_ANDROID_URL = "https://play.google.com/store/apps/details?id=io.heckel.ntfy"
NTFY_FDROID_URL = "https://f-droid.org/en/packages/io.heckel.ntfy/"
NTFY_DOCS_URL = "https://docs.ntfy.sh/subscribe/phone/"
def subscribe_url(topic, server="https://ntfy.sh"):
return f"{server.rstrip('/')}/{topic}"
def _can_encode(sample):
encoding = getattr(sys.stdout, "encoding", None) or ""
if not encoding:
return False
try:
sample.encode(encoding)
except (UnicodeEncodeError, LookupError):
return False
return True
def render_qr(data, border=2, dark_terminal=True):
try:
import qrcode
except ImportError:
return None, "qrcode package not installed"
try:
code = qrcode.QRCode(border=border)
code.add_data(data)
code.make(fit=True)
matrix = code.get_matrix()
except Exception as err:
return None, f"{type(err).__name__}: {err}"
if not _can_encode("\u2588\u2580\u2584"):
return None, "this console cannot render block characters"
def ink(value):
return (not value) if dark_terminal else value
lines = []
for index in range(0, len(matrix), 2):
top = matrix[index]
bottom = matrix[index + 1] if index + 1 < len(matrix) else [False] * len(top)
row = []
for upper, lower in zip(top, bottom):
upper_on = ink(upper)
lower_on = ink(lower)
if upper_on and lower_on:
row.append("\u2588")
elif upper_on:
row.append("\u2580")
elif lower_on:
row.append("\u2584")
else:
row.append(" ")
lines.append("".join(row))
if dark_terminal:
width = len(lines[0]) if lines else 0
pad = "\u2588" * width
lines = [pad] + lines + [pad]
return "\n".join(lines), None
def show_qr(data, label=None, indent=" ", dark_terminal=True):
art, problem = render_qr(data, dark_terminal=dark_terminal)
if label:
print(f"{indent}{label}")
print()
if art is None:
print(f"{indent}[QR unavailable: {problem}]")
print(f"{indent}Open this link on your phone instead:")
print(f"{indent}{data}")
return False
try:
for line in art.splitlines():
print(f"{indent}{line}")
except UnicodeEncodeError:
print(f"{indent}[QR unavailable: console encoding]")
print(f"{indent}{data}")
return False
print()
print(f"{indent}{data}")
return True
def load_config():
if not CONFIG_PATH.exists():
return {}
try:
return load_yaml(CONFIG_PATH)
except Exception:
return {}
def enabled_channels(config=None):
config = config if config is not None else load_config()
return sorted(
name
for name, settings in config.items()
if isinstance(settings, dict) and settings.get("enabled")
)
async def _send_ntfy(session, settings, title, body, priority):
server = str(settings.get("server") or "https://ntfy.sh").rstrip("/")
topic = settings.get("topic")
if not topic:
raise ValueError("ntfy.topic is not set")
headers = {"Title": title}
level = priority or settings.get("priority")
if level:
headers["Priority"] = str(level)
token = settings.get("token")
if token:
headers["Authorization"] = f"Bearer {token}"
async with session.post(
f"{server}/{topic}",
data=body.encode("utf-8"),
headers=headers,
timeout=aiohttp.ClientTimeout(total=SEND_TIMEOUT),
) as response:
if response.status >= 300:
raise RuntimeError(f"HTTP {response.status}")
async def _send_pushover(session, settings, title, body, priority):
user_key = settings.get("user_key")
api_token = settings.get("api_token")
if not user_key or not api_token:
raise ValueError("pushover.user_key and pushover.api_token are required")
payload = {
"token": api_token,
"user": user_key,
"title": title,
"message": body,
"priority": int(priority if priority is not None else settings.get("priority", 0)),
}
if settings.get("sound"):
payload["sound"] = settings["sound"]
if payload["priority"] == 2:
payload["retry"] = 60
payload["expire"] = 3600
async with session.post(
"https://api.pushover.net/1/messages.json",
data=payload,
timeout=aiohttp.ClientTimeout(total=SEND_TIMEOUT),
) as response:
if response.status >= 300:
raise RuntimeError(f"HTTP {response.status}: {await response.text()}")
async def _send_telegram(session, settings, title, body, priority):
token = settings.get("bot_token")
chat_id = settings.get("chat_id")
if not token or not chat_id:
raise ValueError("telegram.bot_token and telegram.chat_id are required")
async with session.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json={"chat_id": str(chat_id), "text": f"{title}\n{body}"},
timeout=aiohttp.ClientTimeout(total=SEND_TIMEOUT),
) as response:
if response.status >= 300:
raise RuntimeError(f"HTTP {response.status}: {await response.text()}")
async def _send_webhook(session, settings, title, body, priority):
url = settings.get("url")
if not url:
raise ValueError("webhook.url is not set")
style = str(settings.get("format") or "json").lower()
method = str(settings.get("method") or "POST").upper()
kwargs = {"timeout": aiohttp.ClientTimeout(total=SEND_TIMEOUT)}
if style == "slack":
kwargs["json"] = {"text": f"*{title}*\n{body}"}
elif style == "discord":
kwargs["json"] = {"content": f"**{title}**\n{body}"}
elif style == "form":
kwargs["data"] = {"title": title, "message": body}
else:
kwargs["json"] = {"title": title, "message": body, "priority": priority}
async with session.request(method, url, **kwargs) as response:
if response.status >= 300:
raise RuntimeError(f"HTTP {response.status}")
def _send_email_blocking(settings, title, body):
recipients = settings.get("recipients") or []
if isinstance(recipients, str):
recipients = [recipients]
sender = settings.get("sender") or settings.get("username")
if not recipients or not sender:
raise ValueError("email.sender and email.recipients are required")
message = EmailMessage()
message["Subject"] = title
message["From"] = sender
message["To"] = ", ".join(recipients)
message.set_content(body)
host = settings.get("host") or "localhost"
port = int(settings.get("port") or 587)
if int(port) == 465:
server = smtplib.SMTP_SSL(host, port, timeout=SEND_TIMEOUT)
else:
server = smtplib.SMTP(host, port, timeout=SEND_TIMEOUT)
try:
if int(port) != 465 and settings.get("use_tls", True):
server.starttls()
if settings.get("username") and settings.get("password"):
server.login(settings["username"], settings["password"])
server.send_message(message)
finally:
try:
server.quit()
except Exception:
pass
async def _send_email(session, settings, title, body, priority):
await asyncio.get_running_loop().run_in_executor(
None, _send_email_blocking, settings, title, body
)
def desktop_backend():
if sys.platform == "darwin":
return "osascript" if shutil.which("osascript") else None
if sys.platform.startswith("win"):
for candidate in ("powershell", "pwsh"):
if shutil.which(candidate):
return candidate
return None
for candidate in ("notify-send", "kdialog", "zenity"):
if shutil.which(candidate):
return candidate
return None
def _desktop_macos(settings, title, body):
escaped_body = body.replace("\\", "\\\\").replace('"', '\\"')
escaped_title = title.replace("\\", "\\\\").replace('"', '\\"')
script = f'display notification "{escaped_body}" with title "{escaped_title}"'
if settings.get("sound"):
sound = str(settings["sound"]).replace('"', "")
script += f' sound name "{sound}"'
return ["osascript", "-e", script]
def _desktop_windows(executable, title, body):
safe_title = title.replace("'", "''")
safe_body = body.replace("'", "''")
script = (
"[reflection.assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; "
"[reflection.assembly]::LoadWithPartialName('System.Drawing') | Out-Null; "
"$n = New-Object System.Windows.Forms.NotifyIcon; "
"$n.Icon = [System.Drawing.SystemIcons]::Information; "
"$n.Visible = $true; "
f"$n.ShowBalloonTip(10000, '{safe_title}', '{safe_body}', "
"[System.Windows.Forms.ToolTipIcon]::Info); "
"Start-Sleep -Seconds 6; "
"$n.Dispose()"
)
return [
executable,
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
]
def _desktop_linux(backend, title, body):
if backend == "notify-send":
return ["notify-send", title, body]
if backend == "kdialog":
return ["kdialog", "--title", title, "--passivepopup", body, "10"]
return ["zenity", "--notification", "--text", f"{title}\n{body}"]
def _send_desktop_blocking(settings, title, body):
backend = desktop_backend()
if backend is None:
if sys.platform.startswith("linux"):
raise RuntimeError(
"no desktop notifier found. Install libnotify-bin "
"(apt install libnotify-bin) or use a push channel instead."
)
raise RuntimeError(f"no desktop notifier available on {sys.platform}")
if backend == "osascript":
command = _desktop_macos(settings, title, body)
elif backend in ("powershell", "pwsh"):
command = _desktop_windows(backend, title, body)
else:
command = _desktop_linux(backend, title, body)
result = subprocess.run(
command, capture_output=True, text=True, timeout=SEND_TIMEOUT
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or f"{backend} failed")
async def _send_desktop(session, settings, title, body, priority):
await asyncio.get_running_loop().run_in_executor(
None, _send_desktop_blocking, settings, title, body
)
SENDERS = {
"ntfy": _send_ntfy,
"pushover": _send_pushover,
"telegram": _send_telegram,
"webhook": _send_webhook,
"email": _send_email,
"desktop": _send_desktop,
"macos": _send_desktop,
}
class Notifier:
def __init__(self, settings, reporter=None, dry_run=False):
self.settings = settings
self.reporter = reporter or (lambda message: None)
self.dry_run = dry_run
self.config = load_config()
self._last_sent = {}
self._last_message = {}
def available(self):
configured = enabled_channels(self.config)
wanted = self.settings.channels
if not wanted:
return configured
return [name for name in wanted if name in configured]
def missing(self):
configured = set(enabled_channels(self.config))
return [name for name in (self.settings.channels or []) if name not in configured]
def throttled(self, key, message, now):
window = self.settings.throttle or 0
last_at = self._last_sent.get(key)
if self._last_message.get(key) == message and last_at and window:
if now - last_at < window:
return f"identical message within {int(window)}s"
if last_at and window and now - last_at < window:
return f"throttled, {int(window - (now - last_at))}s remaining"
return None
async def send(self, title, body, priority=None, key="default", force=False):
channels = self.available()
if not channels:
return False
now = time.monotonic()
if not force:
blocked = self.throttled(key, body, now)
if blocked:
self.reporter(f"notification suppressed: {blocked}")
return False
if self.dry_run:
self.reporter(f"DRY RUN would notify via {', '.join(channels)}: {body}")
self._last_sent[key] = now
self._last_message[key] = body
return True
sent = 0
async with aiohttp.ClientSession() as session:
for name in channels:
sender = SENDERS.get(name)
if sender is None:
continue
try:
await sender(session, self.config.get(name) or {}, title, body, priority)
sent += 1
except Exception as err:
self.reporter(
f"notification via {name} failed: {type(err).__name__}: {err}"
)
if sent:
self._last_sent[key] = now
self._last_message[key] = body
self.reporter(f"notified via {sent} channel(s)")
return sent > 0
async def send_test(channel=None, message=None):
ensure_config()
config = load_config()
available = enabled_channels(config)
if channel:
if channel not in SENDERS:
raise ValueError(f"unknown channel {channel!r}. Known: {sorted(SENDERS)}")
if channel not in available:
raise ValueError(
f"channel {channel!r} is not enabled in {paths.relative(CONFIG_PATH)}"
)
available = [channel]
if not available:
raise ValueError(
f"no channels enabled. Edit {paths.relative(CONFIG_PATH)} and set "
"enabled: true on at least one."
)
title = "solixauto test"
body = message or "Test notification. If you can read this, the channel works."
results = {}
async with aiohttp.ClientSession() as session:
for name in available:
try:
await SENDERS[name](session, config.get(name) or {}, title, body, None)
results[name] = "ok"
except Exception as err:
results[name] = f"{type(err).__name__}: {err}"
return results
+180
View File
@@ -0,0 +1,180 @@
import os
import stat
import sys
from pathlib import Path
_INVOCATION = None
BASE_DIR = Path(os.environ.get("SOLIXAUTO_HOME", Path.home() / "solix-automation"))
DEVICE_PROFILE_DIR = BASE_DIR / "device-profiles"
ANKER_PROFILE_DIR = DEVICE_PROFILE_DIR / "anker"
SHELLY_PROFILE_DIR = DEVICE_PROFILE_DIR / "shelly"
POWER_PROFILE_DIR = BASE_DIR / "power-profiles"
STATE_DIR = BASE_DIR / "state"
LOG_DIR = BASE_DIR / "logs"
RUNTIME_STATE = STATE_DIR / "runtime.json"
ENGINE_LOG = LOG_DIR / "automation.log"
ALL_DIRS = [
BASE_DIR,
DEVICE_PROFILE_DIR,
ANKER_PROFILE_DIR,
SHELLY_PROFILE_DIR,
POWER_PROFILE_DIR,
STATE_DIR,
LOG_DIR,
]
def ensure_dirs():
for directory in ALL_DIRS:
directory.mkdir(parents=True, exist_ok=True)
gitignore = BASE_DIR / ".gitignore"
if not gitignore.exists():
gitignore.write_text(
"device-profiles/\nstate/\nlogs/\n*.env\n.env\n",
encoding="utf-8",
)
return BASE_DIR
def invocation():
global _INVOCATION
if _INVOCATION is not None:
return _INVOCATION
script = sys.argv[0] if sys.argv else ""
stem = Path(script).name if script else ""
if stem in ("solixauto", "solixauto.exe"):
_INVOCATION = "solixauto"
return _INVOCATION
if not script:
_INVOCATION = "solixauto"
return _INVOCATION
interpreter = sys.executable or "python"
display = script
try:
here = Path(script).resolve()
if here.parent == Path.cwd():
display = here.name
except (OSError, ValueError):
pass
_INVOCATION = f"{interpreter} {display}"
return _INVOCATION
def command(rest=""):
base = invocation()
return f"{base} {rest}".rstrip()
def secure_file(path):
path = Path(path)
try:
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
return True
except (OSError, NotImplementedError):
return False
def permissions_enforced():
return not sys.platform.startswith("win")
def relative(path):
path = Path(path)
try:
return str(path.relative_to(BASE_DIR))
except ValueError:
return str(path)
def resolve_profile(reference, kind):
reference = str(reference).strip()
candidate = Path(reference).expanduser()
if candidate.is_absolute() and candidate.exists():
return candidate
roots = [BASE_DIR, DEVICE_PROFILE_DIR]
if kind == "anker":
roots.insert(0, ANKER_PROFILE_DIR)
elif kind == "shelly":
roots.insert(0, SHELLY_PROFILE_DIR)
elif kind == "power":
roots.insert(0, POWER_PROFILE_DIR)
names = [reference]
if not reference.endswith((".yaml", ".yml")):
names.append(reference + ".yaml")
names.append(reference + ".yml")
for root in roots:
for name in names:
probe = root / name
if probe.exists():
return probe
return resolve_by_alias(reference, kind)
def _slug(value):
return "".join(
char.lower() if char.isalnum() else "-" for char in str(value)
).strip("-")
def resolve_by_alias(reference, kind):
import yaml
directories = []
if kind == "anker":
directories.append(ANKER_PROFILE_DIR)
elif kind == "shelly":
directories.append(SHELLY_PROFILE_DIR)
elif kind == "power":
directories.append(POWER_PROFILE_DIR)
else:
directories.extend([ANKER_PROFILE_DIR, SHELLY_PROFILE_DIR, POWER_PROFILE_DIR])
wanted = _slug(reference)
if not wanted:
return None
for directory in directories:
if not directory.exists():
continue
for candidate in sorted(directory.iterdir()):
if candidate.suffix not in (".yaml", ".yml"):
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
haystack = list(data.get("aliases") or [])
identity = data.get("identity") or {}
for key in ("name", "id", "mac", "serial", "part_number"):
if identity.get(key):
haystack.append(identity[key])
access = data.get("access") or {}
if access.get("host"):
haystack.append(access["host"])
if data.get("name"):
haystack.append(data["name"])
for entry in haystack:
if _slug(entry) == wanted:
return candidate
return None
+76
View File
@@ -0,0 +1,76 @@
from datetime import datetime, timezone
from pathlib import Path
import yaml
def now_iso():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def load_yaml(path):
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"profile not found: {path}")
with path.open("r", encoding="utf-8") as handle:
data = yaml.safe_load(handle)
if data is None:
raise ValueError(f"profile is empty: {path}")
if not isinstance(data, dict):
raise ValueError(f"profile must be a mapping at top level: {path}")
return data
def save_yaml(path, data, header=None):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
body = yaml.safe_dump(data, sort_keys=False, default_flow_style=False, width=100)
with path.open("w", encoding="utf-8") as handle:
if header:
for line in header.strip().splitlines():
handle.write(f"# {line}\n" if line.strip() else "#\n")
handle.write("\n")
handle.write(body)
return path
def write_text(path, content):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
def list_profiles(directory):
directory = Path(directory)
if not directory.exists():
return []
return sorted(
[p for p in directory.iterdir() if p.suffix in (".yaml", ".yml")],
key=lambda p: p.name,
)
def slugify(value):
cleaned = []
for char in str(value):
if char.isalnum() or char in "-_":
cleaned.append(char)
elif char in " .:/\\":
cleaned.append("-")
slug = "".join(cleaned).strip("-")
while "--" in slug:
slug = slug.replace("--", "-")
return slug or "device"
def classify(value):
if isinstance(value, bool):
return "bool"
if isinstance(value, int):
return "int"
if isinstance(value, float):
return "float"
if value is None:
return "null"
return "str"
+578
View File
@@ -0,0 +1,578 @@
import ast
import difflib
import re
from pathlib import Path
from . import paths
from .profiles import load_yaml
ALLOWED_NODES = (
ast.Expression,
ast.BoolOp,
ast.And,
ast.Or,
ast.UnaryOp,
ast.Not,
ast.USub,
ast.UAdd,
ast.BinOp,
ast.Add,
ast.Sub,
ast.Mult,
ast.Div,
ast.FloorDiv,
ast.Mod,
ast.Compare,
ast.Lt,
ast.LtE,
ast.Gt,
ast.GtE,
ast.Eq,
ast.NotEq,
ast.In,
ast.NotIn,
ast.Name,
ast.Load,
ast.Constant,
ast.List,
ast.Tuple,
)
ACTIONS = {"target.on", "target.off", "none"}
DURATION_PATTERN = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([smh]?)\s*$", re.IGNORECASE)
DEFAULT_NOTIFY_TEMPLATE = (
"{source_name} battery {battery_soc}%, solar {pv_total}W. "
"{target_name} turned {action}."
)
DEFAULT_NOTIFY_THROTTLE = 300
NOTIFY_EVENTS = {"action", "stale", "error", "start", "safety"}
DEFAULT_DWELL = 60
DEFAULT_STALE_AFTER = 120
DEFAULT_MIN_GAP = 60
DEFAULT_MAX_PER_HOUR = 20
STALE_POLICIES = {"hold", "safe_state", "stop"}
class ProfileError(Exception):
pass
def parse_duration(value, field="duration"):
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
match = DURATION_PATTERN.match(str(value))
if not match:
raise ProfileError(
f"{field}: cannot parse {value!r}. Use forms like 30, 90s, 5m, 1h."
)
amount = float(match.group(1))
unit = (match.group(2) or "s").lower()
return amount * {"s": 1, "m": 60, "h": 3600}[unit]
def format_duration(seconds):
if seconds is None:
return "-"
seconds = round(float(seconds))
if seconds >= 3600 and seconds % 3600 == 0:
return f"{seconds // 3600}h"
if seconds >= 60 and seconds % 60 == 0:
return f"{seconds // 60}m"
if seconds >= 60:
minutes, rest = divmod(seconds, 60)
return f"{minutes}m{rest}s"
return f"{seconds}s"
def compile_expression(source, field="when"):
try:
tree = ast.parse(str(source), mode="eval")
except SyntaxError as err:
raise ProfileError(f"{field}: syntax error in {source!r}: {err.msg}") from err
for node in ast.walk(tree):
if not isinstance(node, ALLOWED_NODES):
raise ProfileError(
f"{field}: {type(node).__name__} is not allowed in {source!r}. "
"Expressions may only use field names, numbers, comparisons, "
"and/or/not, and basic arithmetic."
)
return compile(tree, "<power-profile>", "eval")
def expression_names(source):
tree = ast.parse(str(source), mode="eval")
return {
node.id
for node in ast.walk(tree)
if isinstance(node, ast.Name)
}
def evaluate(code, variables):
return bool(eval(code, {"__builtins__": {}}, dict(variables)))
class BatteryFloor:
def __init__(self, raw):
if not isinstance(raw, dict):
raise ProfileError("safety.battery_floor must be a mapping")
self.enabled = bool(raw.get("enabled", True))
self.field = str(raw.get("field") or "battery_soc")
if "at_or_below" not in raw:
raise ProfileError("safety.battery_floor.at_or_below is required")
try:
self.threshold = float(raw["at_or_below"])
except (TypeError, ValueError):
raise ProfileError(
"safety.battery_floor.at_or_below must be a number"
) from None
release = raw.get("release_at", self.threshold + 15)
try:
self.release = float(release)
except (TypeError, ValueError):
raise ProfileError("safety.battery_floor.release_at must be a number") from None
if self.release <= self.threshold:
raise ProfileError(
f"safety.battery_floor.release_at ({self.release}) must be greater "
f"than at_or_below ({self.threshold}). Without a gap the relay will "
"chatter at the floor."
)
action = str(raw.get("then") or "target.on").strip().lower()
if action not in ("target.on", "target.off"):
raise ProfileError(
"safety.battery_floor.then must be target.on or target.off"
)
self.action = action
self.dwell = parse_duration(raw.get("for", 30), "safety.battery_floor.for")
self.notify = bool(raw.get("notify", True))
self.notify_release = bool(raw.get("notify_release", True))
self.notify_template = raw.get("notify_template") or (
"SAFETY: {source_name} battery at {value}. {target_name} turned "
"{action} to protect it. Holding until {release}."
)
self.release_template = raw.get("release_template") or (
"{source_name} battery recovered to {value}. Normal rules resumed "
"for {target_name}."
)
def desired_state(self):
return self.action == "target.on"
def describe(self):
return (
f"{self.field} <= {self.threshold:g} -> {self.action}, "
f"releases at {self.release:g}"
)
class NotificationSettings:
def __init__(self, raw):
if not isinstance(raw, dict):
raise ProfileError("notifications must be a mapping")
self.enabled = bool(raw.get("enabled", False))
channels = raw.get("channels")
if isinstance(channels, str):
channels = [channels]
self.channels = list(channels or [])
self.template = str(raw.get("template") or DEFAULT_NOTIFY_TEMPLATE)
self.title = str(raw.get("title") or "{profile}")
self.throttle = parse_duration(
raw.get("throttle", DEFAULT_NOTIFY_THROTTLE), "notifications.throttle"
)
self.priority = raw.get("priority")
events = raw.get("on")
if isinstance(events, str):
events = [events]
self.events = set(events or ["action"])
unknown = self.events - NOTIFY_EVENTS
if unknown:
raise ProfileError(
f"notifications.on has unknown event(s) {sorted(unknown)}. "
f"Known: {sorted(NOTIFY_EVENTS)}"
)
def wants(self, event):
return self.enabled and event in self.events
def template_for(self, rule):
if rule is not None and rule.notify_template:
return rule.notify_template
return self.template
def rule_enabled(self, rule):
if not self.enabled:
return False
if rule is None:
return True
if rule.notify is None:
return True
return rule.notify
class Rule:
def __init__(self, raw, index):
if not isinstance(raw, dict):
raise ProfileError(f"rules[{index}]: each rule must be a mapping")
self.name = str(raw.get("name") or f"rule {index + 1}")
label = f"rules[{index}] ({self.name})"
if "when" not in raw:
raise ProfileError(f"{label}: missing 'when'")
self.when_source = str(raw["when"])
self.code = compile_expression(self.when_source, f"{label}.when")
self.names = expression_names(self.when_source)
action = str(raw.get("then") or "").strip().lower()
if action not in ACTIONS:
raise ProfileError(
f"{label}: 'then' must be one of {sorted(ACTIONS)}, got {action!r}"
)
self.action = action
self.dwell = parse_duration(
raw.get("for", DEFAULT_DWELL), f"{label}.for"
)
self.priority = int(raw.get("priority", 0))
self.enabled = bool(raw.get("enabled", True))
notify = raw.get("notify", None)
self.notify_template = None
self.notify_priority = None
self.notify_channels = None
if notify is None:
self.notify = None
elif isinstance(notify, bool):
self.notify = notify
elif isinstance(notify, str):
text = notify.strip().lower()
if text in ("on", "true", "yes"):
self.notify = True
elif text in ("off", "false", "no"):
self.notify = False
else:
raise ProfileError(
f"{label}: notify must be on/off or a mapping, got {notify!r}"
)
elif isinstance(notify, dict):
self.notify = bool(notify.get("enabled", True))
self.notify_template = notify.get("template")
self.notify_priority = notify.get("priority")
channels = notify.get("channels")
if isinstance(channels, str):
channels = [channels]
self.notify_channels = channels
else:
raise ProfileError(f"{label}: notify must be on/off or a mapping")
def desired_state(self):
if self.action == "target.on":
return True
if self.action == "target.off":
return False
return None
def evaluate(self, variables):
return evaluate(self.code, variables)
class PowerProfile:
def __init__(self, path):
self.path = Path(path)
raw = load_yaml(self.path)
self.name = str(raw.get("name") or self.path.stem)
self.enabled = bool(raw.get("enabled", True))
self.description = str(raw.get("description") or "")
source = raw.get("source")
if not isinstance(source, dict) or not source.get("profile"):
raise ProfileError("source.profile is required")
self.source_reference = str(source["profile"])
self.stale_after = parse_duration(
source.get("stale_after", DEFAULT_STALE_AFTER), "source.stale_after"
)
self.on_stale = str(source.get("on_stale", "hold")).strip().lower()
if self.on_stale not in STALE_POLICIES:
raise ProfileError(
f"source.on_stale must be one of {sorted(STALE_POLICIES)}, "
f"got {self.on_stale!r}"
)
target = raw.get("target")
if not isinstance(target, dict) or not target.get("profile"):
raise ProfileError("target.profile is required")
self.target_reference = str(target["profile"])
self.target_channel = target.get("channel")
safe_state = raw.get("safe_state", "off")
if isinstance(safe_state, bool):
self.safe_state = safe_state
else:
text = str(safe_state).strip().lower()
if text not in ("on", "off"):
raise ProfileError("safe_state must be 'on' or 'off'")
self.safe_state = text == "on"
raw_rules = raw.get("rules")
if not isinstance(raw_rules, list) or not raw_rules:
raise ProfileError("at least one rule is required")
self.rules = [Rule(item, index) for index, item in enumerate(raw_rules)]
self.notifications = NotificationSettings(raw.get("notifications") or {})
safety = raw.get("safety") or {}
if not isinstance(safety, dict):
raise ProfileError("safety must be a mapping")
floor = safety.get("battery_floor")
self.battery_floor = BatteryFloor(floor) if floor else None
limits = raw.get("limits") or {}
self.min_gap = parse_duration(
limits.get("min_seconds_between_actions", DEFAULT_MIN_GAP),
"limits.min_seconds_between_actions",
)
self.max_per_hour = int(limits.get("max_actions_per_hour", DEFAULT_MAX_PER_HOUR))
if self.max_per_hour < 1:
raise ProfileError("limits.max_actions_per_hour must be at least 1")
self.poll_interval = parse_duration(raw.get("poll_interval", 10), "poll_interval")
self.source_path = paths.resolve_profile(self.source_reference, "anker")
self.target_path = paths.resolve_profile(self.target_reference, "shelly")
def active_rules(self):
return [rule for rule in self.rules if rule.enabled]
def referenced_names(self):
names = set()
for rule in self.active_rules():
names |= rule.names
return names
def known_fields(anker_profile):
readable = set((anker_profile.get("readable") or {}).keys())
derived = set((anker_profile.get("derived") or {}).keys())
return readable, derived
def derived_values(anker_profile, status):
values = dict(status)
for name, spec in (anker_profile.get("derived") or {}).items():
expression = spec.get("expression") if isinstance(spec, dict) else spec
if not expression:
continue
try:
code = compile_expression(expression, f"derived.{name}")
values[name] = eval(code, {"__builtins__": {}}, dict(values))
except Exception:
values[name] = None
return values
def validate(profile):
problems = []
notes = []
if profile.source_path is None:
problems.append(
f"source.profile {profile.source_reference!r} does not resolve to a file "
f"under {paths.relative(paths.ANKER_PROFILE_DIR)}"
)
if profile.target_path is None:
problems.append(
f"target.profile {profile.target_reference!r} does not resolve to a file "
f"under {paths.relative(paths.SHELLY_PROFILE_DIR)}"
)
if problems:
return problems, notes
anker_profile = load_yaml(profile.source_path)
shelly_profile = load_yaml(profile.target_path)
if anker_profile.get("kind") != "anker":
problems.append(f"{profile.source_path} is not an Anker device profile")
if shelly_profile.get("kind") != "shelly":
problems.append(f"{profile.target_path} is not a Shelly device profile")
readable, derived = known_fields(anker_profile)
available = readable | derived
for rule in profile.active_rules():
unknown = sorted(rule.names - available)
for name in unknown:
close = sorted(
candidate
for candidate in available
if name.lower() in candidate.lower()
or candidate.lower() in name.lower()
)[:3]
if not close:
close = difflib.get_close_matches(name, sorted(available), n=3, cutoff=0.5)
hint = f" Did you mean: {', '.join(close)}?" if close else ""
problems.append(f"rule {rule.name!r}: unknown field {name!r}.{hint}")
channels = shelly_profile.get("channels") or {}
if profile.target_channel is not None:
if str(profile.target_channel) not in channels:
problems.append(
f"target.channel {profile.target_channel!r} not present on device. "
f"Available: {sorted(channels)}"
)
elif len(channels) > 1:
notes.append(
f"target.channel not set and device has {len(channels)} channels; "
f"channel {sorted(channels)[0]} will be used"
)
device_automation = shelly_profile.get("device_automation") or {}
if device_automation.get("checked"):
from .shelly import automation_warnings
for warning in automation_warnings(device_automation, profile.target_channel):
notes.append(f"target device has its own automation: {warning}")
if profile.battery_floor is None:
notes.append(
"no safety.battery_floor is set. If this target controls charging for "
"the source device, add one so the battery cannot be stranded at 0%"
)
elif profile.battery_floor.field not in available:
problems.append(
f"safety.battery_floor.field {profile.battery_floor.field!r} is not a "
"field on this device"
)
on_rules = [r for r in profile.active_rules() if r.desired_state() is True]
off_rules = [r for r in profile.active_rules() if r.desired_state() is False]
if not on_rules:
notes.append("no rule ever turns the target ON")
if not off_rules:
notes.append("no rule ever turns the target OFF")
for rule in profile.active_rules():
if rule.dwell is not None and rule.dwell < 15:
notes.append(
f"rule {rule.name!r}: dwell of {format_duration(rule.dwell)} is short "
"and may cause the relay to chatter"
)
_check_hysteresis(profile, notes)
if profile.min_gap and profile.poll_interval and profile.min_gap < profile.poll_interval:
notes.append(
"limits.min_seconds_between_actions is shorter than poll_interval"
)
if profile.notifications.enabled:
from . import notify as notify_module
configured = set(notify_module.enabled_channels())
wanted = set(profile.notifications.channels)
if not configured:
notes.append(
"notifications are enabled but no channel is turned on in "
"notifications.yaml"
)
elif wanted and not (wanted & configured):
notes.append(
f"notifications request channel(s) {sorted(wanted)} but only "
f"{sorted(configured)} are enabled in notifications.yaml"
)
templates_to_check = [profile.notifications.template, profile.notifications.title]
for rule in profile.active_rules():
if rule.notify_template:
templates_to_check.append(rule.notify_template)
context_names = {
"profile", "rule", "action", "action_word", "source_name", "source_model",
"source_serial", "target_name", "target_model", "target_host",
"target_channel", "condition", "time", "reason", "event",
}
for template in templates_to_check:
for field in notify_module.template_fields(template):
if field in context_names or field in available:
continue
problems.append(
f"notification template references unknown field {field!r}"
)
if profile.auth_warning(shelly_profile):
notes.append(
"the Shelly device reports authentication enabled but no credentials "
"are set in its profile; control calls will fail"
)
return problems, notes
def _check_hysteresis(profile, notes):
thresholds = {}
for rule in profile.active_rules():
state = rule.desired_state()
if state is None:
continue
try:
tree = ast.parse(rule.when_source, mode="eval")
except SyntaxError:
continue
for node in ast.walk(tree):
if not isinstance(node, ast.Compare):
continue
if not isinstance(node.left, ast.Name):
continue
if len(node.comparators) != 1:
continue
comparator = node.comparators[0]
if not isinstance(comparator, ast.Constant):
continue
if not isinstance(comparator.value, (int, float)):
continue
thresholds.setdefault(node.left.id, []).append(
(state, comparator.value, rule.name)
)
for field, entries in thresholds.items():
values = {}
for state, value, rule_name in entries:
values.setdefault(value, set()).add(state)
for value, states in values.items():
if len(states) > 1:
notes.append(
f"field {field!r} uses the same threshold {value} for both ON and "
"OFF; separate them to create a deadband"
)
def _auth_warning(self, shelly_profile):
auth = shelly_profile.get("auth") or {}
if not auth.get("required"):
return False
return not (auth.get("username") and auth.get("password"))
PowerProfile.auth_warning = _auth_warning
+253
View File
@@ -0,0 +1,253 @@
import os
import subprocess
import sys
from pathlib import Path
from . import paths
LAUNCHD_DIR = Path.home() / "Library" / "LaunchAgents"
SYSTEMD_DIR = Path.home() / ".config" / "systemd" / "user"
def platform_kind():
if sys.platform == "darwin":
return "launchd"
if sys.platform.startswith("linux"):
return "systemd"
if sys.platform.startswith("win"):
return "windows"
return "unknown"
def service_label(profile_name):
return f"com.solixauto.{profile_name}"
def script_path():
if sys.argv and sys.argv[0]:
return str(Path(sys.argv[0]).resolve())
return "solixauto.py"
def env_file_in_use():
explicit = os.environ.get("SOLIXAUTO_ENV")
if explicit and Path(explicit).expanduser().exists():
return str(Path(explicit).expanduser())
candidates = [
Path.cwd() / ".env",
paths.BASE_DIR / ".env",
Path.home() / "anker-solix-mqtt" / "anker-solix-api" / ".env",
Path.home() / "anker-solix-api" / ".env",
]
for candidate in candidates:
if candidate.exists():
return str(candidate)
return None
def environment():
values = {}
env_file = env_file_in_use()
if env_file:
values["SOLIXAUTO_ENV"] = env_file
if os.environ.get("SOLIXAUTO_HOME"):
values["SOLIXAUTO_HOME"] = os.environ["SOLIXAUTO_HOME"]
return values
def launchd_plist(profile_name):
label = service_label(profile_name)
script = script_path()
log_dir = paths.LOG_DIR
env_entries = ""
for key, value in environment().items():
env_entries += f" <key>{key}</key>\n <string>{value}</string>\n"
env_block = ""
if env_entries:
env_block = (
" <key>EnvironmentVariables</key>\n"
" <dict>\n"
f"{env_entries}"
" </dict>\n"
)
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
<string>{sys.executable}</string>
<string>{script}</string>
<string>run</string>
<string>{profile_name}</string>
<string>--quiet</string>
</array>
<key>WorkingDirectory</key>
<string>{Path(script).parent}</string>
{env_block} <key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>60</integer>
<key>StandardOutPath</key>
<string>{log_dir / f"{profile_name}.out.log"}</string>
<key>StandardErrorPath</key>
<string>{log_dir / f"{profile_name}.err.log"}</string>
</dict>
</plist>
"""
def systemd_unit(profile_name):
script = script_path()
env_lines = "".join(
f"Environment={key}={value}\n" for key, value in environment().items()
)
return f"""[Unit]
Description=solixauto {profile_name}
After=network-online.target
[Service]
Type=simple
ExecStart={sys.executable} {script} run {profile_name} --quiet
WorkingDirectory={Path(script).parent}
{env_lines}Restart=always
RestartSec=60
[Install]
WantedBy=default.target
"""
def windows_instructions(profile_name):
script = script_path()
label = f"solixauto-{profile_name}"
return f"""Windows does not have a per-user service manager as simple as the
others. Use Task Scheduler:
schtasks /create /tn "{label}" /sc onlogon /rl highest ^
/tr "\\"{sys.executable}\\" \\"{script}\\" run {profile_name} --quiet"
To remove it:
schtasks /delete /tn "{label}" /f
Task Scheduler will not restart the task if it crashes. For that, set
the task's "If the task fails, restart every" option in the GUI, under
task properties, Settings.
"""
def unit_path(profile_name):
kind = platform_kind()
if kind == "launchd":
return LAUNCHD_DIR / f"{service_label(profile_name)}.plist"
if kind == "systemd":
return SYSTEMD_DIR / f"solixauto-{profile_name}.service"
return None
def render(profile_name):
kind = platform_kind()
if kind == "launchd":
return launchd_plist(profile_name)
if kind == "systemd":
return systemd_unit(profile_name)
if kind == "windows":
return windows_instructions(profile_name)
return None
def run_command(command):
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=30)
except Exception as err:
return False, f"{type(err).__name__}: {err}"
output = (result.stdout + result.stderr).strip()
return result.returncode == 0, output
def install(profile_name):
kind = platform_kind()
destination = unit_path(profile_name)
if destination is None:
return False, "no service manager available on this platform"
destination.parent.mkdir(parents=True, exist_ok=True)
paths.LOG_DIR.mkdir(parents=True, exist_ok=True)
destination.write_text(render(profile_name), encoding="utf-8")
if kind == "launchd":
label = service_label(profile_name)
run_command(["launchctl", "unload", str(destination)])
ok, output = run_command(["launchctl", "load", "-w", str(destination)])
return ok, output or f"loaded {label}"
ok, output = run_command(["systemctl", "--user", "daemon-reload"])
if not ok:
return False, output
ok, output = run_command(
["systemctl", "--user", "enable", "--now", destination.name]
)
return ok, output or f"enabled {destination.name}"
def uninstall(profile_name):
kind = platform_kind()
destination = unit_path(profile_name)
if destination is None:
return False, "no service manager available on this platform"
if kind == "launchd":
ok, output = run_command(["launchctl", "unload", "-w", str(destination)])
else:
run_command(["systemctl", "--user", "disable", "--now", destination.name])
ok, output = run_command(["systemctl", "--user", "daemon-reload"])
if destination.exists():
destination.unlink()
return True, output or "removed"
def status(profile_name):
kind = platform_kind()
destination = unit_path(profile_name)
if destination is None or not destination.exists():
return False, "not installed"
if kind == "launchd":
ok, output = run_command(["launchctl", "list"])
label = service_label(profile_name)
for line in output.splitlines():
if label in line:
fields = line.split()
pid = fields[0]
if pid != "-":
return True, f"running, pid {pid}"
return True, f"loaded but not running (last exit {fields[1]})"
return False, "installed but not loaded"
ok, output = run_command(
["systemctl", "--user", "is-active", destination.name]
)
return ok, output or "unknown"
+845
View File
@@ -0,0 +1,845 @@
import asyncio
import os
import subprocess
import sys
from pathlib import Path
from . import notify, paths
from .profiles import list_profiles, load_yaml, save_yaml, slugify, write_text
STRATEGIES = {
"battery": {
"label": "Battery only - charge from the wall when low, stop when full",
"detail": (
"The safest place to start. Uses only battery percentage, which is "
"the most reliable field on every model."
),
"needs_solar": False,
},
"solar": {
"label": "Solar aware - stop grid charging once the sun covers the load",
"detail": (
"Adds rules using solar input. Only pick this once you have watched "
"the solar fields in daylight and confirmed they match the Anker app."
),
"needs_solar": True,
},
"manual": {
"label": "Write my own rules later",
"detail": "Creates the profile with the safety floor only.",
"needs_solar": False,
},
}
PROFILE_TEMPLATE = """# Power profile: __NAME__
#
# Created by the setup wizard. Edit freely; rerun the validator after:
# __INVOCATION__ run __NAME__ --test
#
# Field names come from the device profile. List them with:
# __INVOCATION__ fields __SOURCE__
name: __NAME__
description: >
__DESCRIPTION__
enabled: true
poll_interval: 10s
source:
profile: __SOURCE__
stale_after: 300s
on_stale: safe_state
target:
profile: __TARGET__
channel: __CHANNEL__
safe_state: __SAFE_STATE__
# Checked before every rule below. Bypasses the rate limits and latches once
# tripped, so the battery cannot be stranded flat.
safety:
battery_floor:
at_or_below: __FLOOR__
release_at: __RELEASE__
then: target.on
for: 30s
notify: true
notify_release: true
notifications:
enabled: __NOTIFY__
channels: __CHANNELS__
title: "{profile}"
template: >-
{source_name} battery {battery_soc}%.
{target_name} turned {action}.
throttle: 5m
on:
- action
- stale
rules:
__RULES__
limits:
min_seconds_between_actions: 60
max_actions_per_hour: 20
"""
BATTERY_RULES = """ - name: top up from grid when low
when: battery_soc <= __LOW__
for: 2m
then: target.on
- name: stop charging when full enough
when: battery_soc >= __HIGH__
for: 2m
then: target.off
"""
SOLAR_RULES = """ - name: top up from grid when low
when: battery_soc <= __LOW__
for: 2m
then: target.on
- name: stop charging when full enough
when: battery_soc >= __HIGH__
for: 2m
then: target.off
- name: solar is carrying the load, stay off the grid
when: pv_surplus > 200 and battery_soc > 50
for: 15m
then: target.off
- name: solar cannot keep up, fall back to the grid
when: pv_surplus < -200 and battery_soc <= 50
for: 15m
then: target.on
"""
MANUAL_RULES = """ - name: placeholder, replace me
when: battery_soc <= 20
for: 2m
then: target.on
"""
def header(step, total, title):
print()
print("=" * 64)
print(f"STEP {step} of {total} {title}")
print("=" * 64)
def note(text):
for line in text.strip().splitlines():
print(f" {line.strip()}")
def pip_install(packages, editable_repo=None):
command = [sys.executable, "-m", "pip", "install", "--upgrade"]
if editable_repo:
command.append(editable_repo)
else:
command.extend(packages)
print()
print(f" running: {' '.join(command)}")
result = subprocess.run(command)
return result.returncode == 0
MODULE_TO_PACKAGE = {
"yaml": "pyyaml",
"aiohttp": "aiohttp",
"aiofiles": "aiofiles",
"cryptography": "cryptography",
"paho": "paho-mqtt",
"dotenv": "python-dotenv",
"qrcode": "qrcode",
"zeroconf": "zeroconf",
"ifaddr": "ifaddr",
"yarl": "yarl",
"dateutil": "python-dateutil",
"tzlocal": "tzlocal",
"Crypto": "pycryptodome",
"websockets": "websockets",
"requests": "requests",
}
DEEP_PROBE = "import anker_solix_api.api, anker_solix_api.mqtt_factory"
def probe(statement):
result = subprocess.run(
[sys.executable, "-c", statement], capture_output=True, text=True
)
if result.returncode == 0:
return None
import re
match = re.search(r"No module named '([^']+)'", result.stderr)
if match:
return match.group(1)
return result.stderr.strip() or "unknown import error"
def check_dependencies(prompt, confirm):
own = [
("yaml", "pyyaml"),
("aiohttp", "aiohttp"),
("qrcode", "qrcode"),
("zeroconf", "zeroconf"),
("dotenv", "python-dotenv"),
]
missing = []
for module, package in own:
try:
__import__(module)
except ImportError:
missing.append(package)
if missing:
print()
print(f" Missing: {', '.join(missing)}")
if confirm(" Install them now?", default=True):
if not pip_install(missing):
print(" Install failed. Fix that, then rerun.")
return False
else:
return False
else:
print(" Python packages: all present")
print(" Checking the Anker library and everything it needs...")
for attempt in range(12):
problem = probe(DEEP_PROBE)
if problem is None:
print(" anker-solix-api: ready")
return True
if problem == "anker_solix_api":
print()
note(
"""
anker-solix-api is not installed for this interpreter. It is the
library that talks to Anker's cloud, and it is not on PyPI.
"""
)
if not confirm(" Install it from GitHub now?", default=True):
print()
note(
"""
Skipped. Nothing can read your Anker device without it.
Install it when ready, then rerun this setup:
git clone https://github.com/thomluther/anker-solix-api.git
pip install -e anker-solix-api
"""
)
return False
if not pip_install(
None, "git+https://github.com/thomluther/anker-solix-api.git"
):
print()
note(
"""
That install did not work. Install it by hand, then rerun:
git clone https://github.com/thomluther/anker-solix-api.git
pip install -e anker-solix-api
"""
)
return False
continue
if " " in problem:
print()
print(f" The Anker library failed to import: {problem}")
return False
root = problem.split(".")[0]
package = MODULE_TO_PACKAGE.get(root, root)
print(f" anker-solix-api needs {package}, installing...")
if not pip_install([package]):
print(f" Could not install {package}.")
return False
print(" Dependency resolution did not settle. Install by hand and rerun.")
return False
def ensure_credentials(prompt, confirm):
from .credentials import load_credentials
try:
user, _, country = load_credentials()
masked = user[:2] + "***" + user[-8:] if len(user) > 10 else "***"
print(f" Found Anker credentials for {masked} (country {country})")
if not confirm(" Use these?", default=True):
raise RuntimeError("user chose to re-enter")
return True
except Exception:
pass
import getpass
print()
note(
"""
Your Anker account email and password are needed to read telemetry.
They are written only to a local file with owner-only permissions.
The password is not shown while you type.
"""
)
print()
email = prompt(" Anker account email")
password = getpass.getpass(" Anker account password: ").strip()
if not password:
print(" Password cannot be empty.")
return False
country = prompt(" Country code", "US").upper()
destination = paths.BASE_DIR / ".env"
def escape(value):
return value.replace("\\", "\\\\").replace('"', '\\"')
descriptor = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write(f'ANKERUSER="{escape(email)}"\n')
handle.write(f'ANKERPASSWORD="{escape(password)}"\n')
handle.write(f'ANKERCOUNTRY="{escape(country)}"\n')
paths.secure_file(destination)
os.environ["SOLIXAUTO_ENV"] = str(destination)
for key in ("ANKERUSER", "ANKERPASSWORD", "ANKERCOUNTRY"):
os.environ.pop(key, None)
print()
print(f" Saved to {destination}")
from .credentials import load_credentials
try:
check_user, _, _ = load_credentials()
except Exception as err:
print(f" But it could not be read back: {err}")
return False
if check_user != email:
print(" But reading it back gave a different value. Something is wrong")
print(f" with the file at {destination}.")
return False
print(" Verified readable.")
if not paths.permissions_enforced():
print(" Note: Windows does not enforce file permissions. Keep this")
print(" directory out of any synced or shared folder.")
return True
def pick_profile(kind, directory, choose, confirm, label):
found = [p for p in list_profiles(directory) if p.name != "README.md"]
if not found:
return None
if len(found) == 1:
data = load_yaml(found[0])
name = (data.get("identity") or {}).get("name") or found[0].stem
print(f" Found one {label}: {name}")
return found[0]
options = []
for path in found:
data = load_yaml(path)
identity = data.get("identity") or {}
display = identity.get("name") or path.stem
extra = identity.get("model") or identity.get("part_number") or ""
options.append((str(path), f"{display} ({extra})"))
print()
print(f" Which {label}?")
print()
chosen = choose(options, " Choose")
return Path(chosen)
def run_setup(prompt, choose, confirm):
total = 8
print()
print("SOLIXAUTO SETUP")
print()
note(
"""
This walks through everything: dependencies, your Anker account,
finding your devices, notifications, writing an automation profile,
testing it, and starting it in the background.
Nothing switches any hardware until you say so near the end.
Press Ctrl-C at any point to stop; nothing is left half-done.
"""
)
print()
print(f" Working directory: {paths.BASE_DIR}")
paths.ensure_dirs()
header(1, total, "Dependencies")
if not check_dependencies(prompt, confirm):
return False
header(2, total, "Anker account")
if not ensure_credentials(prompt, confirm):
return False
header(3, total, "Find your Anker device")
from . import anker
existing = [p for p in list_profiles(paths.ANKER_PROFILE_DIR)]
if existing and not confirm(
f" {len(existing)} Anker device profile(s) already saved. Search again?",
default=False,
):
print(" Keeping what is already saved.")
else:
note(
"""
Make sure the device is powered on, awake, and connected to wifi.
Models that only pair over Bluetooth cannot be used.
"""
)
print()
try:
asyncio.run(anker.discover())
except Exception as err:
print(f" Discovery failed: {type(err).__name__}: {err}")
return False
source = pick_profile(
"anker", paths.ANKER_PROFILE_DIR, choose, confirm, "Anker device"
)
if source is None:
print(" No Anker device found. Cannot continue.")
return False
source_data = load_yaml(source)
source_fields = set((source_data.get("readable") or {}).keys()) | set(
(source_data.get("derived") or {}).keys()
)
header(4, total, "Find your Shelly plug")
from . import shelly
existing = list_profiles(paths.SHELLY_PROFILE_DIR)
if existing and not confirm(
f" {len(existing)} Shelly profile(s) already saved. Search again?",
default=False,
):
print(" Keeping what is already saved.")
else:
note(
"""
Scanning the local network. This takes up to a minute.
"""
)
try:
asyncio.run(shelly.discover())
except Exception as err:
print(f" Discovery failed: {type(err).__name__}: {err}")
return False
target = pick_profile(
"shelly", paths.SHELLY_PROFILE_DIR, choose, confirm, "Shelly plug"
)
if target is None:
print()
note(
"""
No Shelly found. If it is on a different subnet, rerun discovery
with --network or --host, then start this wizard again.
"""
)
return False
target_data = load_yaml(target)
channels = target_data.get("channels") or {"0": {}}
channel = 0
if len(channels) > 1:
options = [
(index, f"channel {index}: {entry.get('name', '')}")
for index, entry in sorted(channels.items())
]
print()
print(" Which channel controls the Anker device?")
print()
channel = int(choose(options, " Choose"))
if not (target_data.get("identity") or {}).get("name"):
print()
if confirm(" This plug has no name. Give it one now?", default=True):
new_name = prompt(" Name")
target = rename_profile(target, new_name)
target_data = load_yaml(target)
header(5, total, "Check the plug for competing automation")
conflicts = check_conflicts(target, channel, confirm)
if conflicts is False:
return False
header(6, total, "Notifications")
setup_notifications(confirm)
header(7, total, "Automation rules")
profile_path = build_profile(
source, target, channel, source_fields, prompt, choose, confirm
)
if profile_path is None:
return False
header(8, total, "Test, then start it")
return finish(profile_path, confirm)
def rename_profile(path, new_name):
data = load_yaml(path)
identity = data.setdefault("identity", {})
old = identity.get("name") or path.stem
identity["name"] = new_name
aliases = list(data.get("aliases") or [])
for candidate in (new_name, old, path.stem):
if candidate and candidate not in aliases:
aliases.append(candidate)
data["aliases"] = aliases
destination = path.parent / f"{slugify(new_name).lower()}.yaml"
save_yaml(path, data)
if destination != path and not destination.exists():
path.replace(destination)
print(f" Renamed to {destination.name}")
return destination
return path
def check_conflicts(target_path, channel, confirm):
import aiohttp
from .shelly import (
ShellyTarget,
automation_warnings,
clear_auto_timer,
delete_schedule,
set_initial_state,
)
device = ShellyTarget(target_path, channel)
async def go():
async with aiohttp.ClientSession() as session:
automation = await device.automation(session)
warnings = automation_warnings(automation, channel)
if not warnings:
print(" Nothing on the plug will fight your rules.")
return True
print()
print(f" Found {len(warnings)} thing(s) set on the plug itself:")
for item in warnings:
print(f" {item}")
print()
note(
"""
These run on the device whether or not this tool is running,
and they will override it. Removing them is recommended.
"""
)
print()
if not confirm(" Remove them now?", default=True):
print(" Left in place. Expect them to interfere.")
return True
for job in automation.get("schedules") or []:
if job.get("enabled") and job.get("id") is not None:
ok = await delete_schedule(
session, device.host, job["id"], device.generation, device.auth
)
print(f" {'removed' if ok else 'FAILED'}: {job['description']}")
for index, values in (automation.get("timers") or {}).items():
if str(index) != str(channel):
continue
for key, value in values.items():
if key == "initial_state":
if str(value).lower() == "off":
ok = await set_initial_state(
session, device.host, index, "on",
device.generation, device.auth,
)
print(
f" {'power-up set to on' if ok else 'FAILED to set power-up state'}"
)
else:
ok = await clear_auto_timer(
session, device.host, index, key,
device.generation, device.auth,
)
print(f" {'disabled' if ok else 'FAILED to disable'} {key}")
return True
try:
return asyncio.run(go())
except Exception as err:
print(f" Could not check the plug: {type(err).__name__}: {err}")
return True
def setup_notifications(confirm):
enabled = notify.enabled_channels()
if enabled:
print(f" Already configured: {', '.join(enabled)}")
return True
note(
"""
Notifications tell you when the automation switches something, and
when the battery hits its safety floor. Strongly recommended if the
machine will be running unattended.
"""
)
print()
if not confirm(" Set up push notifications now?", default=True):
print(" Skipped. You can do this later with: notify-setup")
return False
topic = notify.generate_topic()
url = notify.subscribe_url(topic)
print()
note(
"""
Using ntfy: free, open source, no account needed.
A random private topic has been generated. Anyone who knows it can
read your alerts, so do not share it.
"""
)
print()
notify.show_qr(notify.NTFY_IOS_URL, "1. Scan to install the app (iPhone):")
print()
print(f" Android: {notify.NTFY_ANDROID_URL}")
print()
try:
input(" Press Enter once the app is installed...")
except EOFError:
pass
print()
notify.show_qr(url, "2. Scan to open your topic, or add it by hand:")
print()
print(f" topic: {topic}")
print(" server: ntfy.sh")
print()
print(" On iPhone the QR opens a browser page. Use the app's + button")
print(" and paste the topic instead.")
print()
try:
input(" Press Enter once you have subscribed in the app...")
except EOFError:
pass
notify.apply_settings("ntfy", {"enabled": True, "topic": topic})
print()
print(" Sending a test...")
try:
results = asyncio.run(notify.send_test("ntfy"))
outcome = results.get("ntfy")
print(f" ntfy: {outcome}")
except Exception as err:
print(f" test failed: {err}")
return True
def build_profile(source, target, channel, fields, prompt, choose, confirm):
print()
note(
"""
What should the plug do? Pick the closest fit; you can edit the file
afterwards.
"""
)
print()
has_solar = "pv_surplus" in fields
options = []
for key, entry in STRATEGIES.items():
if entry["needs_solar"] and not has_solar:
continue
options.append((key, entry["label"]))
strategy = choose(options, " Choose")
print()
note(STRATEGIES[strategy]["detail"])
controls_charging = confirm(
"\n Does this plug supply power TO the Anker device?", default=True
)
print()
if controls_charging:
note(
"""
Then turning the plug ON charges the Anker device. The safety floor
and stale-telemetry behaviour will both fail toward charging.
"""
)
else:
note(
"""
Then the plug runs some other load. Failure states will leave it
off rather than on.
"""
)
low = high = None
if strategy != "manual":
print()
low = int(prompt(" Charge when battery drops to (%)", "35"))
high = int(prompt(" Stop charging at (%)", "85"))
while high <= low + 10:
print(" Leave at least 10 points between them, or the plug will")
print(" switch constantly. Try again.")
low = int(prompt(" Charge when battery drops to (%)", "35"))
high = int(prompt(" Stop charging at (%)", "85"))
print()
floor = int(prompt(" Emergency floor, never let battery fall below (%)", "20"))
release = int(prompt(" Hold emergency charging until (%)", str(floor + 20)))
while release <= floor:
release = int(prompt(f" Must be above {floor}. Hold until (%)", str(floor + 20)))
if strategy == "battery":
rules = BATTERY_RULES
elif strategy == "solar":
rules = SOLAR_RULES
else:
rules = MANUAL_RULES
if low is not None:
rules = rules.replace("__LOW__", str(low)).replace("__HIGH__", str(high))
name = prompt("\n Name for this automation", "battery-charging")
name = slugify(name).lower()
channels = notify.enabled_channels()
content = PROFILE_TEMPLATE
for token, value in (
("__NAME__", name),
("__DESCRIPTION__", STRATEGIES[strategy]["label"]),
("__SOURCE__", source.name),
("__TARGET__", target.name),
("__CHANNEL__", str(channel)),
("__SAFE_STATE__", "on" if controls_charging else "off"),
("__FLOOR__", str(floor)),
("__RELEASE__", str(release)),
("__NOTIFY__", "true" if channels else "false"),
("__CHANNELS__", "[" + ", ".join(channels) + "]"),
("__RULES__", rules),
("__INVOCATION__", paths.invocation()),
):
content = content.replace(token, value)
destination = paths.POWER_PROFILE_DIR / f"{name}.yaml"
if destination.exists() and not confirm(
f" {destination.name} already exists. Overwrite?", default=False
):
print(" Keeping the existing file.")
return destination
write_text(destination, content)
print()
print(f" Wrote {paths.relative(destination)}")
return destination
def finish(profile_path, confirm):
from . import service
from .engine import dry_run_report
from .rules import PowerProfile, ProfileError
try:
profile = PowerProfile(profile_path)
except ProfileError as err:
print(f" The generated profile is invalid: {err}")
return False
print()
print(" Checking it against your devices, without switching anything...")
try:
ok = asyncio.run(dry_run_report(profile, cycles=2))
except Exception as err:
print(f" Test failed: {type(err).__name__}: {err}")
return False
if not ok:
print(" Fix the problems above, then rerun.")
return False
installed, state = service.status(profile.path.stem)
if installed:
print()
print(f" WARNING: a service named {profile.path.stem!r} already exists")
print(f" and is {state}.")
print()
print(" Installing again will replace it and point it at:")
print(f" {paths.BASE_DIR}")
print()
if not confirm(" Replace the existing service?", default=False):
print(" Left the existing service alone.")
return True
print()
if not confirm(" Start this automation in the background now?", default=True):
print()
print(" Nothing is running. When you are ready:")
print(f" {paths.command('run ' + profile.path.stem)}")
print(f" {paths.command('service ' + profile.path.stem)}")
return True
ok, detail = service.install(profile.path.stem)
print()
if not ok:
print(f" Could not install the service: {detail}")
print(f" Run it manually with: {paths.command('run ' + profile.path.stem)}")
return False
running, state = service.status(profile.path.stem)
print(" DONE. The automation is running.")
print()
print(f" status {state}")
print(f" log {paths.ENGINE_LOG}")
print(f" profile {profile.path}")
print()
print(" Useful later:")
print(f" {paths.command('service ' + profile.path.stem + ' --status')}")
print(f" {paths.command('service ' + profile.path.stem + ' --uninstall')}")
print(f" tail -f {paths.ENGINE_LOG}")
if sys.platform == "darwin":
print()
print(" This stops if the Mac sleeps. In System Settings > Energy,")
print(" prevent automatic sleeping, and set 'Start up when power is")
print(" connected' to Always.")
return True
+790
View File
@@ -0,0 +1,790 @@
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
+529
View File
@@ -0,0 +1,529 @@
from . import paths
from .profiles import list_profiles, write_text
POWER_PROFILE_TEMPLATE = """# Power profile: __NAME__
#
# Links ONE Anker SOLIX device (read-only data source) to ONE Shelly
# switch channel (the actuator). Rules decide when the Shelly turns on
# or off. The Anker device is never commanded.
#
# Validate before running:
# __INVOCATION__ run __NAME__ --test
#
# ---------------------------------------------------------------------
# QUICK REFERENCE
# ---------------------------------------------------------------------
# when: an expression over fields from the Anker device profile.
# Use field names directly. Allowed: < <= > >= == !=
# and / or / not, + - * /, and parentheses.
# Run `__INVOCATION__ fields __SOURCE__` to list every name.
#
# for: how long the condition must hold continuously before acting.
# Prevents a passing cloud from toggling the relay. 30s 5m 1h.
#
# then: target.on, target.off, or none.
#
# priority: when several rules are ready at once, the highest number
# wins. Default 0.
#
# notify: on or off per rule. Omit to inherit the profile default.
# Only matters when notifications.enabled is true below.
#
# ---------------------------------------------------------------------
name: __NAME__
description: >
Describe what this profile is for.
enabled: true
poll_interval: 10s
source:
profile: __SOURCE__
stale_after: 120s
on_stale: safe_state
target:
profile: __TARGET__
channel: __CHANNEL__
safe_state: on
# SAFETY FLOOR - evaluated before any rule below, and it bypasses the rate
# limits. Once tripped it LATCHES: nothing can turn the target off again until
# the battery climbs back to release_at. Set this whenever the target controls
# charging for the source device, so the battery can never be stranded at 0%.
safety:
battery_floor:
at_or_below: 25
release_at: 45
then: target.on
for: 30s
notify: true
notify_release: true
# Notifications fire when a rule actually switches the target.
# Channels and credentials live in ../notifications.yaml, not here.
# solixauto notify-test
notifications:
enabled: false
channels: []
title: "{profile}"
template: >-
{source_name} battery {battery_soc}%, solar {pv_total}W.
{target_name} turned {action}.
throttle: 5m
on:
- action
rules:
__RULES__
limits:
min_seconds_between_actions: 60
max_actions_per_hour: 20
"""
RULES_SOLAR = """ - name: grid assist when solar drops
when: pv_total < 150
for: 90s
then: target.on
- name: release grid when solar recovers
when: pv_total > 300
for: 2m
then: target.off
- name: emergency charge on low battery
when: battery_soc <= 15
for: 30s
then: target.on
priority: 100
notify:
template: >-
{source_name} has {battery_soc}% battery remaining.
{target_name} turned on AC power.
priority: high
"""
RULES_BATTERY = """ - name: charge when battery is low
when: battery_soc <= 15
for: 30s
then: target.on
notify:
template: >-
{source_name} has {battery_soc}% battery remaining.
{target_name} turned on AC power.
- name: stop charging when battery is healthy
when: battery_soc >= 60
for: 2m
then: target.off
"""
RULES_MINIMAL = """ - name: turn on
when: battery_soc <= 20
for: 60s
then: target.on
- name: turn off
when: battery_soc >= 50
for: 60s
then: target.off
"""
RULE_SETS = {
"solar": RULES_SOLAR,
"battery": RULES_BATTERY,
"minimal": RULES_MINIMAL,
}
README_HEADER = """# Power profiles
> Commands below are written as `solixauto`. If that is not on your PATH, run
> them the same way you ran the tool, for example:
>
> __INVOCATION__ run <profile> --test
"""
README = """# Power profiles
A power profile is a plain YAML file linking one Anker SOLIX device to one
Shelly switch channel. You can edit it in any text editor.
The Anker device is **read-only**. The engine reads its telemetry and never
sends it a command. The only thing that gets switched is the Shelly.
## Layout
device-profiles/anker/ generated, one file per Anker device
device-profiles/shelly/ generated, one file per Shelly device
power-profiles/ yours, hand-edited
state/runtime.json last known target state per profile
logs/automation.log action history
## Workflow
solixauto discover-anker
solixauto discover-shelly
solixauto new-profile solar-failover --template solar
solixauto fields A1782-<serial>
solixauto run solar-failover --test
solixauto run solar-failover
## Anatomy of a rule
- name: grid assist when solar drops
when: pv_total < 150
for: 90s
then: target.on
priority: 0
`when` is an expression over any field listed in the Anker device profile,
under `readable` or `derived`. `solixauto fields <device>` prints them with
current sample values.
`for` is the dwell time. The condition must stay true for this long before
anything happens. Without it, a cloud passing over your array would cycle the
relay repeatedly.
`then` is `target.on`, `target.off`, or `none`.
`priority` breaks ties. If two rules are ready at the same moment and disagree,
the higher number wins. A low-battery override should outrank normal solar
logic.
## Use a deadband
This is the single most important thing to get right:
# WRONG - will chatter around 200W
- when: pv_total < 200
then: target.on
- when: pv_total > 200
then: target.off
# RIGHT - 150W of deadband between the two
- when: pv_total < 150
then: target.on
- when: pv_total > 300
then: target.off
`--test` warns when it detects the same threshold used for both directions.
## Useful automations
**Solar failover.** Solar covers the load most of the day; pull from the wall
only when production drops.
- name: grid assist when solar drops
when: pv_total < 150
for: 90s
then: target.on
- name: release grid when solar recovers
when: pv_total > 300
for: 2m
then: target.off
**Low-battery charge.** No solar involved.
- name: charge when battery is low
when: battery_soc <= 15
for: 30s
then: target.on
- name: stop charging when battery is healthy
when: battery_soc >= 60
for: 2m
then: target.off
**Overnight top-up.** Combine conditions.
- name: cheap overnight charging
when: battery_soc < 80 and pv_total < 20
for: 5m
then: target.on
**Let solar do the work.** Stop grid charging once the sun is carrying the load
and has spare capacity for the battery. `pv_surplus` is solar minus everything
drawing from the unit, so positive means the surplus is going into the battery.
- name: solar covers the load, stop grid charging
when: pv_surplus > 100
for: 5m
then: target.off
- name: solar cannot keep up, fall back to grid
when: pv_surplus < -100 and battery_soc <= 60
for: 10m
then: target.on
The 100W band on either side of zero is the deadband; without it, a load
cycling on and off would flip the relay every few minutes. The SOC condition on
the second rule stops it reaching for the grid on a cloudy afternoon when the
battery is still comfortable.
**Load shedding.** Cut a non-essential circuit when the battery is draining.
- name: shed load
when: battery_soc < 30 and ac_input_power == 0
for: 2m
then: target.off
**Thermal guard.** Higher priority so it outranks everything else.
- name: stop charging when hot
when: temperature >= 45
for: 60s
then: target.off
priority: 200
## Safety settings
`stale_after` and `on_stale` control what happens when telemetry stops
arriving. Rules are never evaluated against stale data.
- `hold` keeps the relay wherever it is. Default and safest.
- `safe_state` drives the relay to the `safe_state:` value.
- `stop` exits the engine.
`limits` is a hard backstop independent of dwell times:
limits:
min_seconds_between_actions: 60
max_actions_per_hour: 20
If a rule somehow oscillates, this caps the damage.
## The safety floor
This is not a rule. It is checked before every rule, it ignores the rate limits,
and once tripped it latches.
safety:
battery_floor:
at_or_below: 25
release_at: 45
then: target.on
for: 30s
notify: true
Set this whenever the Shelly controls charging for the Anker device itself.
Without it you can deadlock: a rule turns charging off, the battery runs flat,
the device drops off wifi, telemetry goes stale, and nothing ever turns charging
back on.
`release_at` must be meaningfully higher than `at_or_below`. While latched, no
rule can turn the target off, so the battery gets a real recharge instead of
being released at 26% into the same conditions that drained it.
Pair it with a stale policy that fails toward charging:
source:
stale_after: 300s
on_stale: safe_state
safe_state: on
`--test` warns when no floor is set.
### Floor notifications
The floor notifies on both trip and release, and it does this **even when
`notifications.enabled` is false**. Turning off routine solar chatter should not
silence a battery emergency. All it needs is one enabled channel in
`notifications.yaml`. Push channels get urgent priority, and the throttle is
bypassed.
safety:
battery_floor:
at_or_below: 25
notify: true
notify_release: true
notify_template: >-
SAFETY: {source_name} battery at {value}. {target_name} turned
{action} to protect it. Holding until {release}.
release_template: >-
{source_name} battery recovered to {value}. Normal rules resumed
for {target_name}.
Extra fields available in these two templates: `{value}`, `{field}`,
`{threshold}`, `{release}`.
If the floor trips and no channel is configured, the log says so explicitly
rather than failing silently.
## Conflicts with the Shelly's own automation
A Shelly can run schedules, auto-on/auto-off timers, and webhooks entirely on
the device. Those keep running whether or not this engine is running, and they
will fight your rules. A schedule that turns the plug off at 08:00 will undo a
rule that just turned it on, and nothing in the log will explain why.
Check before you rely on anything:
solixauto conflicts <shelly-profile>
To remove them without leaving the terminal:
solixauto conflicts <shelly-profile> --fix
That asks before every change and defaults to No. Deletions happen on the
device and cannot be undone from here; you would have to recreate them in the
Shelly app.
Discovery records what it finds, `--test` lists it as notes, and the engine
prints a loud warning at startup if anything is still there.
Remove them in the Shelly app, or accept that the device wins.
One limitation: schedules created as Shelly Cloud **scenes** live in the cloud
rather than on the device, and cannot be seen over local HTTP. If behaviour
still looks wrong after `conflicts` comes back clean, check the app's scenes.
## Notifications
Turn them on in the profile:
notifications:
enabled: true
channels: [ntfy]
title: "{profile}"
template: >-
{source_name} battery {battery_soc}%, solar {pv_total}W.
{target_name} turned {action}.
throttle: 5m
on:
- action
Then per rule, `notify: on` or `notify: off` to include or exclude it. Omit it
to inherit. A rule can also carry its own wording:
- name: emergency charge on low battery
when: battery_soc <= 15
for: 30s
then: target.on
priority: 100
notify:
template: >-
{source_name} has {battery_soc}% battery remaining.
{target_name} turned on AC power.
priority: high
### Template fields
Any field from the device profile works, plus:
{profile} power profile name
{rule} rule that fired
{condition} the rule's when expression
{action} ON or OFF
{action_word} on or off
{source_name} Anker device name, falling back to model
{source_model} e.g. SOLIX F3000
{source_serial}
{target_name} Shelly device name, falling back to model
{target_model}
{target_host}
{time}
`--test` checks every field name in your templates against the device profile,
so a typo is caught before it ships a message reading `battery ?%`.
### Channels
Credentials live in `../notifications.yaml`, which is created with owner-only
permissions. Power profiles stay free of secrets.
- **ntfy** - recommended. Free, no account. Install the app, pick an
unguessable topic name, subscribe. Anyone who knows the topic can read your
alerts, so make it long.
- **pushover** - $5 once per platform. Priority 2 alerts repeat until you
acknowledge them.
- **email** - SMTP. Gmail requires an App Password.
- **telegram** - free bot.
- **webhook** - Slack, Discord, or generic JSON.
- **desktop** - local notification on the machine running the engine.
Uses osascript on macOS, notify-send on Linux, PowerShell on Windows.
Run `solixauto doctor` to see which backend was detected.
The Anker app and the Shelly app cannot receive custom push messages from an
external program, so neither is an option here.
Test a channel:
solixauto notify-test
solixauto notify-test --channel ntfy
### Throttling
`throttle` is per rule. An identical repeated message inside the window is
dropped. This is separate from the switching rate limits, so a stuck condition
cannot flood your phone even if the relay is behaving.
### Other events
on:
- action
- stale
`stale` fires once when telemetry stops arriving and is worth enabling if you
depend on the automation.
## Testing
solixauto run <profile> --test
Validates syntax, checks that every field you reference actually exists on the
device, warns about missing deadbands and short dwell times, connects to both
devices, and prints what each rule would do right now. Nothing is switched.
solixauto run <profile> --test --offline
Same checks without connecting. Rules are evaluated against the sample values
captured during discovery.
"""
def scaffold_readme():
destination = paths.POWER_PROFILE_DIR / "README.md"
header = README_HEADER.replace("__INVOCATION__", paths.invocation())
body = README.split("\n", 1)[1] if README.startswith("# Power profiles") else README
write_text(destination, header.rstrip() + "\n" + body)
return destination
def default_source():
profiles = list_profiles(paths.ANKER_PROFILE_DIR)
return profiles[0].name if profiles else "REPLACE-WITH-ANKER-PROFILE.yaml"
def default_target():
profiles = list_profiles(paths.SHELLY_PROFILE_DIR)
return profiles[0].name if profiles else "REPLACE-WITH-SHELLY-PROFILE.yaml"
def render(name, source=None, target=None, channel=0, template="solar"):
rules = RULE_SETS.get(template, RULES_SOLAR)
output = POWER_PROFILE_TEMPLATE
for token, value in (
("__NAME__", name),
("__SOURCE__", source or default_source()),
("__TARGET__", target or default_target()),
("__CHANNEL__", str(channel)),
("__RULES__", rules),
("__INVOCATION__", paths.invocation()),
):
output = output.replace(token, value)
return output
def create(name, source=None, target=None, channel=0, template="solar", force=False):
paths.ensure_dirs()
destination = paths.POWER_PROFILE_DIR / f"{name}.yaml"
if destination.exists() and not force:
raise FileExistsError(destination)
write_text(destination, render(name, source, target, channel, template))
scaffold_readme()
return destination