commit 56e75d9cde8bd3dbc0c6fb4351834e18434f56ec Author: Sterling Archer Date: Mon Aug 10 21:37:46 2026 -0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d00c48e --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +venv/ +.venv/ +env/ + +.env +*.env +notifications.yaml +device-profiles/ +power-profiles/ +state/ +logs/ +*.log + +.DS_Store +.idea/ +.vscode/ +*.swp + +!examples/notifications.yaml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fd85d26 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing + +## Before opening an issue + +Run `solixauto doctor` and include its output. It reports platform, Python +version, dependencies, whether credentials resolve, detected subnets, and +notification status. + +**Do not paste secrets.** Never include: + +- `notifications.yaml` or any part of it +- your `.env` file, Anker email, or password +- your ntfy topic (anyone with it can read your alerts) + +Device profiles contain serial numbers. Redact them if that matters to you. + +## Reporting a device that does not work + +Include: + +- model and part number, e.g. `A1782` +- what `solixauto discover-anker` printed +- whether the device shows as online in the Anker app at the time +- for Shelly devices, generation and model, plus `solixauto conflicts ` + +Devices that pair over Bluetooth only cannot be supported here. They publish +nothing to Anker's cloud MQTT broker. That is a hardware limitation, not a bug. + +## Field mappings + +Telemetry field names come from the upstream +[anker-solix-api](https://github.com/thomluther/anker-solix-api) project. +Corrections to field meanings belong there, not here. + +If a field is wrong or missing for your model, that is the right place to +report it. This project only adds derived fields computed from what upstream +already decodes. + +## Code changes + +- No comments in code; the project is written without them +- Match the existing style rather than introducing a formatter +- Anything touching the engine needs a matching check in `--test` output. If + a behaviour cannot be observed with `--test`, it is very hard for a user to + trust it. +- Safety-relevant changes (the battery floor, stale handling, rate limits) + should come with a description of the failure mode they address + +## Testing + +There is no unit test suite yet. Contributions that add one are welcome. + +At minimum, exercise the paths you touched: + +```bash +solixauto run --test --offline +solixauto run --test --simulate battery_soc=12 +solixauto run --dry-run --cycles 6 +``` + +A fake Shelly is easy to stand up with aiohttp if you need to test the control +path without hardware; the RPC surface used is small: `/shelly`, +`/rpc/Shelly.GetStatus`, `/rpc/Shelly.GetConfig`, `/rpc/Switch.Set`, +`/rpc/Switch.SetConfig`, `/rpc/Schedule.List`, `/rpc/Schedule.Delete`, +`/rpc/Webhook.List`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a57161e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Justin Oros + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a98f4a --- /dev/null +++ b/README.md @@ -0,0 +1,405 @@ +# anker-shelly-bridge + +Automate a Shelly smart plug from live Anker SOLIX telemetry. + +Your SOLIX device reports battery level, solar input, and load over Anker's +cloud MQTT service. This reads that telemetry and switches a Shelly plug on +your local network according to rules you write in a plain text file. + +The common use is controlling grid charging: plug the SOLIX into a Shelly, and +let the battery level decide when to draw from the wall. + +**The SOLIX device is read-only.** This tool never sends it a command. The +Shelly plug is the only thing it ever switches. + +> Unofficial and unaffiliated with Anker or Allterco Robotics. SOLIX and Shelly +> are trademarks of their respective owners. Built on the community +> [anker-solix-api](https://github.com/thomluther/anker-solix-api) library. + +--- + +## Quick start + +You need a SOLIX device that connects to WiFi, a Shelly plug on the same +network, and your Anker account login. + +```bash +git clone https://github.com/YOURNAME/anker-shelly-bridge.git +cd anker-shelly-bridge +./start.sh +``` + +On Windows, double-click `start.bat`. + +That is the whole install. `start.sh` creates its own Python environment, +installs everything, and hands over to a guided setup with eight steps: + +1. **Dependencies** — installs whatever is missing +2. **Anker account** — your login, stored locally with owner-only permissions +3. **Find your SOLIX device** — connects and captures every field it reports +4. **Find your Shelly plug** — scans the local network +5. **Check the plug** — finds schedules and timers already on it that would + conflict, and offers to remove them +6. **Notifications** — optional push to your phone, with a QR code to scan +7. **Rules** — pick a strategy and answer a few questions in plain language +8. **Test, then start** — a live dry run against your hardware that switches + nothing, then offers to install a background service + +Nothing touches your hardware until step 8 asks. Ctrl-C is safe at any point. + +Needs Python 3.12+ and git. If they are missing, `start.sh` tells you how to +install them for your platform. + +--- + +## What it does + +### Reads your SOLIX device + +Connects to Anker's cloud MQTT broker, captures a live telemetry snapshot, and +writes a device profile you can read. A portable power station typically +reports around 44 fields: battery percentage, per-string solar input, AC and DC +output, temperature, port states. + +It also computes derived fields, so rules can use `pv_total` instead of adding +`pv_1_power` and `pv_2_power` by hand: + +| Field | Meaning | +|---|---| +| `pv_total` | all solar strings added together, in watts | +| `pv_surplus` | solar minus load; positive means the surplus is charging the battery | +| `pv_covers_load` | true when solar alone is carrying everything | +| `usb_total` | combined USB output | +| `soc_headroom` | percentage points above the configured floor | + +```bash +solixauto fields # every usable field name +solixauto status --watch # live values +``` + +### Controls your Shelly plug + +Local HTTP only, no cloud. Gen1 and Gen2+ including Gen4. Discovery uses mDNS +plus a subnet scan across every private network it can see, so machines with +several interfaces work. + +```bash +solixauto switch on|off|status +``` + +Every command reads the state back from the device afterwards rather than +trusting the HTTP response. + +### Runs your rules + +A power profile is a YAML file linking one SOLIX device to one Shelly channel: + +```yaml +rules: + - name: top up from grid when low + when: battery_soc <= 35 + for: 2m + then: target.on + + - name: stop charging when full enough + when: battery_soc >= 85 + for: 2m + then: target.off +``` + +`when` is an expression over any field the device reports. `for` is how long +the condition must hold before anything happens, so a passing cloud or a +momentary load spike cannot toggle the relay. `priority` breaks ties when rules +disagree. + +Expressions run in a sandbox allowing only comparisons, `and`/`or`/`not`, and +arithmetic. Function calls and attribute access are rejected at parse time, so +a power profile cannot execute code. + +Full rule reference with worked examples: [docs/RULES.md](docs/RULES.md). +The same document is written into your data directory during setup. + +--- + +## Safety + +Controlling the power supply to a battery has a specific failure mode: turn +charging off, let the battery run flat, and the device drops off the network — +at which point nothing can turn charging back on. The design is built around +preventing that. + +### The battery floor + +```yaml +safety: + battery_floor: + at_or_below: 20 + release_at: 40 + then: target.on +``` + +Checked before any rule. It **bypasses the rate limits**, **outranks every +rule**, and **latches** once tripped — nothing can turn the plug off again +until the battery reaches `release_at`. Without the latch, a solar rule could +release it at 21% straight back into whatever drained it. + +`release_at` must be meaningfully above `at_or_below`; the profile is rejected +otherwise. + +### Failing in the safe direction + +```yaml +source: + stale_after: 300s + on_stale: safe_state +safe_state: on +``` + +If telemetry stops arriving, rules are never evaluated against stale values. +`hold` leaves the plug alone, `safe_state` drives it to a known state, `stop` +exits. When the plug supplies power to the SOLIX device, `safe_state: on` means +losing sight of it fails toward charging. + +Rules are also never evaluated against **partial** telemetry. If a field a rule +needs has not arrived yet, nothing is evaluated at all — including the floor. + +### Rate limits + +```yaml +limits: + min_seconds_between_actions: 60 + max_actions_per_hour: 20 +``` + +A hard backstop independent of dwell times. If a rule somehow oscillates, this +caps the damage. The battery floor deliberately ignores it. + +### Conflict detection + +A Shelly can hold schedules, auto-on/auto-off timers, and webhooks on the +device itself. They run whether or not this tool is running, and they silently +override it. + +```bash +solixauto conflicts # list them +solixauto conflicts --fix # remove them, asking before each change +``` + +Checked at discovery, at `--test`, and again at engine startup. + +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` is clean, check the app's scenes. + +--- + +## Testing before you trust it + +```bash +solixauto run --test +``` + +Validates syntax, checks every field name against what the device actually +reports, warns about missing deadbands and short dwell times, connects to both +devices, and prints what each rule would do right now. Switches nothing. + +```bash +solixauto run --test --offline +``` + +Same checks without connecting, evaluated against values captured at discovery. + +```bash +solixauto run --test --simulate battery_soc=12 +``` + +Force a rule to fire without waiting for real conditions. Prove your +low-battery logic works at 2pm on a sunny day, and see the exact notification +text it would send. + +```bash +solixauto run --dry-run +``` + +The full engine loop with real telemetry, narrating every cycle, but no switch +command and no notification. + +--- + +## Notifications + +```bash +solixauto notify-setup +``` + +Interactive: pick a channel, it configures and tests it. For ntfy it generates +a random private topic, shows QR codes for the app store and the topic, and +sends a test push. + +Supported: **ntfy** (free, no account), **Pushover**, **email**, **Telegram**, +**webhook** (Slack/Discord/custom), and **desktop** (osascript on macOS, +notify-send on Linux, PowerShell on Windows). + +Credentials live in `notifications.yaml` with owner-only permissions, never in +power profiles, so profiles stay safe to share. + +Battery floor alerts fire **even when notifications are otherwise disabled**, +at elevated priority, bypassing the throttle. Muting routine chatter should not +silence a battery emergency. + +The Anker app and the Shelly app cannot receive custom push messages from an +external program, so neither is an option. + +--- + +## Running it unattended + +```bash +solixauto service # install and start +solixauto service --status +solixauto service --uninstall +``` + +macOS gets a LaunchAgent, Linux a systemd user unit, both starting at login and +restarting on crash. Windows prints the Task Scheduler command. + +If the machine sleeps, the automation sleeps with it and the safety floor +cannot protect anything. Use an always-on machine, or disable sleep. + +--- + +## Layout + +Code lives where you cloned it. Everything else lives in `~/solix-automation`: + +``` +~/solix-automation/ +├── venv/ Python environment created by start.sh +├── device-profiles/ +│ ├── anker/ what your SOLIX device reports +│ └── shelly/ your plugs and their capabilities +├── power-profiles/ your rules, hand-editable +│ └── README.md full rule reference with worked examples +├── notifications.yaml channel credentials, mode 0600 +├── state/runtime.json last known target state +└── logs/automation.log every action taken +``` + +Override the root with `SOLIXAUTO_HOME`. + +--- + +## Commands + +``` +solixauto setup guided setup, start here +solixauto doctor check this machine is set up correctly + +solixauto discover-anker find and profile SOLIX devices +solixauto discover-shelly find and profile Shelly devices +solixauto devices list saved device profiles +solixauto name "" name a device, rename its file +solixauto fields field names usable in rules +solixauto status live telemetry +solixauto switch on|off manual control +solixauto conflicts automation set on the plug itself + +solixauto new-profile scaffold a power profile +solixauto profiles list power profiles +solixauto validate check without connecting +solixauto run --test test against real devices, switch nothing +solixauto run run it + +solixauto notify-setup configure push notifications +solixauto notify-test send a test +solixauto notify-qr show the ntfy topic QR again + +solixauto service run in the background at login +``` + +Profiles resolve by friendly name, serial, ID, MAC, or IP, so all of these +reach the same device: + +```bash +solixauto switch "Garage Plug" on +solixauto switch 192.168.1.50 on +solixauto switch garage-plug on +``` + +--- + +## Device support + +Anything the upstream +[anker-solix-api](https://github.com/thomluther/anker-solix-api) library +supports **and** that holds a cloud connection. + +Models pairing with the Anker app over Bluetooth only — press a button on the +unit to make it discoverable — publish nothing to the MQTT broker and cannot be +used. The F2000 / PowerHouse 767 works this way. Discovery detects and skips +them. + +Shelly Gen1 and Gen2+ including Gen4, over local HTTP. + +Developed against a SOLIX F3000 (A1782) and a Shelly Plug US Gen4. Other +combinations should work; reports welcome. + +--- + +## Known limitations + +- Field mappings come from a community reverse-engineering effort, not from + Anker. Fields ending in `?` or starting with `unknown_` are unconfirmed — + do not build rules on them. +- **Verify any field against the Anker app before trusting it.** The solar + fields in particular should be watched in daylight and compared to the app + before writing solar rules. +- Shelly Cloud scenes and app-set device names are invisible over local HTTP. +- Requires a machine that stays awake. +- Anker rate-limits logins; avoid restarting the service in a tight loop. + +--- + +## Troubleshooting + +**A device reports no telemetry.** It is powered off, asleep, off WiFi, or +Bluetooth-only. Confirm it shows online in the Anker app, then retry that +device alone with `--sn `. + +**Shelly discovery finds nothing.** Try `--host 192.168.1.50` or +`--network 192.168.1.0/24`. Give your plugs DHCP reservations; `access.host` is +a fixed address in the profile. + +**The automation does nothing.** Check `solixauto service --status` +and the log. Rules only act after their `for:` dwell has fully elapsed. + +**Something switches the plug unexpectedly.** Run `solixauto conflicts `, +then check the Shelly app for cloud scenes. + +**Anything else.** `solixauto doctor` reports platform, dependencies, +credentials, network, and notification status in one place. + +--- + +## Documentation + +- [docs/RULES.md](docs/RULES.md) — power profile format, every option, worked + automation examples +- [docs/TESTING.md](docs/TESTING.md) — staged checklist for validating a new + setup before trusting it with real hardware +- [examples/](examples/) — a solar failover profile and an annotated + notifications config + +## Contributing + +Bug reports welcome, particularly for device models not listed above. Include +the output of `solixauto doctor` and the relevant section of +`logs/automation.log`. + +**Never paste `notifications.yaml`, your `.env`, or an ntfy topic** into an +issue. Device profiles contain serial numbers; redact them if that matters to +you. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/docs/RULES.md b/docs/RULES.md new file mode 100644 index 0000000..033d762 --- /dev/null +++ b/docs/RULES.md @@ -0,0 +1,232 @@ +# 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- + 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 ` 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 + +**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. + +## 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 --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 --test --offline + +Same checks without connecting. Rules are evaluated against the sample values +captured during discovery. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..33252b5 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,232 @@ +# Testing before you publish + +Work down this list. Each stage only depends on the ones above it, so a failure +tells you exactly which layer is broken. Nothing switches real hardware until +stage 5. + +## What is already verified + +Rule parsing, the expression sandbox, dwell timing, priority resolution, rate +limiting, profile validation, template rendering, and notification throttling +all have test coverage. + +## What has never run against real hardware + +Every Anker library call, every Shelly HTTP call, every notification channel, +and the engine loop end to end. Those are what these stages exercise. + +--- + +## Stage 1 - environment + + python solixauto.py doctor + +Expect: no PROBLEM lines. Warnings about optional packages are fine. + +If `anker-solix-api` shows MISSING, you are running the wrong interpreter. Use +the venv that already works with mqtt_monitor. + +--- + +## Stage 2 - Anker discovery + + python solixauto.py discover-anker + +Expect: one profile per owned device, each reporting a field count. + + python solixauto.py devices + python solixauto.py fields A1782- + +Check specifically: + +- `pv_total` appears under derived, and equals `pv_1_power + pv_2_power` +- `battery_soc` is present and matches the Anker app right now +- the field count is roughly what mqtt_monitor showed you + +**If a device reports no telemetry:** it is powered off, asleep, off WiFi, or +Bluetooth-only. + +Some models pair with the Anker app over Bluetooth only. You press a button on +the unit to make it discoverable and it holds no persistent cloud connection. +The F2000 / PowerHouse 767 works this way. Those devices publish nothing to +Anker's MQTT broker and the broker refuses the subscription with +`Unspecified error(128)`. They cannot be automation sources here — local +Bluetooth access would need a different project (SolixBLE). + +Discovery skips devices the cloud reports as disconnected before subscribing, +so they cost no time. To keep one out permanently: + + python solixauto.py discover-anker --skip + +For a device that is genuinely cloud connected but reported otherwise, force it +with `--include-offline`. + +Otherwise, confirm it shows as online in the Anker app, then rerun for that +device alone with `--sn `. + +Discovery never overwrites a good profile with an empty one, so re-running with +a device switched off is harmless. + +**Most likely failure:** an `AttributeError` or `TypeError` from +`update_sites`, `get_bind_devices`, `startMqttSession`, or the device factory. +I derived those calls from the library's C1000X example rather than running +them. If one breaks, send me the traceback and the exact line. + +**Also check:** verify `pv_total` against the Anker app using LIVE data, not the +samples in the profile. `fields` shows the snapshot captured at discovery time; +use `status` for a live read: + + python solixauto.py status --fields pv battery --watch + +Open the Anker app side by side and compare the combined solar watts. If they +disagree, the field mapping is wrong and every solar rule you write will be +wrong with it. This is community-reverse-engineered, not documented by Anker. + +Worth watching for a few minutes across a change in conditions, so you can see +`pv_total` track the app rather than matching once by coincidence. + +--- + +## Stage 3 - Shelly discovery + + python solixauto.py discover-shelly + +Expect: one profile per Shelly, with the right host and channel count. + +If mDNS finds nothing, fall back: + + python solixauto.py discover-shelly --host 192.168.1.50 + python solixauto.py discover-shelly --network 192.168.1.0/24 + +Give every Shelly a DHCP reservation before going further. If a plug changes IP, +automation silently stops working. + +--- + +## Stage 4 - manual switch + +This is the first stage that moves a relay. **Plug the Shelly into a lamp, not +into anything that matters.** + + python solixauto.py switch status + python solixauto.py switch on + python solixauto.py switch off + +Expect: state reads back correctly, and the command is confirmed by re-reading +the device rather than trusting the HTTP response. + +If this fails, control is broken and no rule will work. Check auth in the +profile if the device has a password set. + +--- + +## Stage 4b - check for competing automation + + python solixauto.py conflicts + +A Shelly can hold schedules, auto-off timers, and webhooks on the device itself. +They run independently of this tool and will silently override it. Remove them +in the Shelly app before going further, or you will spend a long time debugging +rules that were working correctly. + +Shelly Cloud scenes are not visible locally, so also check the app's scenes. + +## Stage 5 - notifications + + python solixauto.py notify-setup + # enable one channel, then + python solixauto.py notify-test + +Expect: `ok` per channel and a message on your phone. + +ntfy tip: use a long random topic name. Anyone who knows it can read your +alerts. + +--- + +## Stage 6 - profile validation + + python solixauto.py new-profile solar-failover --template solar + python solixauto.py run solar-failover --test --offline + +Expect: syntax OK, no PROBLEM lines, and a rendered notification per rule with +your real device names in it. + +--- + +## Stage 7 - forced rule firing + +Prove the logic without waiting for the sun to set: + + python solixauto.py run solar-failover --test --simulate battery_soc=12 pv_total=0 + +Expect: the low-battery rule reports TRUE, the release rule reports false, and +the notification text reads the way you want it to read. Nothing is switched. + +Try a few combinations. This is where you catch a rule that is inverted or a +threshold that is off by a decimal place. + +--- + +## Stage 8 - live dry run + + python solixauto.py run solar-failover --test + +Now it connects to both devices with real telemetry. Expect: Shelly reachable, +real values, dwell countdowns. + +Then run the real loop with switching still disabled: + + python solixauto.py run solar-failover --dry-run + +Leave it for an hour. Expect log lines saying what it *would* do. Confirm the +decisions match what you would have made by hand. + +--- + +## Stage 9 - live, on a lamp + + python solixauto.py run solar-failover + +Still on the lamp. Watch for a full day, ideally one with variable cloud, which +is what exposes missing deadbands. + +Check `logs/automation.log` for: + +- action counts that look sane, not dozens per hour +- no `suppressed` lines hitting the hourly cap, which means a rule is + oscillating +- notifications arriving when you expect and not otherwise + +--- + +## Stage 10 - real load + +Only after stage 9 has been clean for a full day. Move the Shelly to the real +circuit and keep watching the log for another day. + +--- + +## Stage 11 - run it unattended + + python solixauto.py service + python solixauto.py service --status + +Installs a LaunchAgent on macOS or a systemd user unit on Linux, starting at +login and restarting on crash. Check the log after an hour, and again after a +reboot, before trusting it. + +Removing the service leaves the Shelly in whatever state it was last set to. +Check the plug before you walk away. + +## Before publishing + +- [ ] `device-profiles/` and `state/` are gitignored (the generated `.gitignore` + covers this, but verify — profiles contain your serial numbers) +- [ ] `notifications.yaml` is not committed +- [ ] no `.env` in the repo +- [ ] `git log -p | grep -i -E "password|token|@gmail|user_key"` comes back empty +- [ ] README states the project is unofficial and unaffiliated with Anker or + Allterco +- [ ] no Anker or Shelly logos in the repo +- [ ] the example profiles reference placeholder serials, not yours diff --git a/examples/notifications.yaml b/examples/notifications.yaml new file mode 100644 index 0000000..2fafe67 --- /dev/null +++ b/examples/notifications.yaml @@ -0,0 +1,81 @@ +# 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/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 diff --git a/examples/solar-failover.yaml b/examples/solar-failover.yaml new file mode 100644 index 0000000..c735245 --- /dev/null +++ b/examples/solar-failover.yaml @@ -0,0 +1,86 @@ +# Example power profile: solar failover +# +# 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. +# +# Replace the source and target filenames with your own. List them with: +# solixauto devices +# +# Validate before running: +# solixauto run solar-failover --test + +name: solar-failover +description: > + Charge from the grid when the battery is low or solar cannot keep up. + Stay off the grid whenever the sun is carrying the load. + +enabled: true + +poll_interval: 10s + +source: + profile: REPLACE-WITH-YOUR-ANKER-PROFILE.yaml + stale_after: 300s + on_stale: safe_state + +target: + profile: REPLACE-WITH-YOUR-SHELLY-PROFILE.yaml + channel: 0 + +# The plug supplies power TO the Anker device, so every failure path should +# end with charging enabled rather than disabled. +safe_state: on + +# Checked before every rule below, bypasses the rate limits, and latches once +# tripped so nothing can turn charging off again until the battery recovers. +safety: + battery_floor: + at_or_below: 20 + release_at: 40 + then: target.on + for: 30s + notify: true + notify_release: true + +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 + - stale + +rules: + - name: top up from grid when low + when: battery_soc <= 35 + for: 2m + then: target.on + + - name: stop charging when full enough + when: battery_soc >= 85 + for: 2m + then: target.off + + # pv_surplus is solar minus everything drawing from the unit. Positive means + # the sun is covering the load and the excess is charging the battery. + # + # The long dwell matters: a variable load such as a desktop PC will cross + # zero constantly, and a short dwell would cycle the relay all afternoon. + - 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 + +limits: + min_seconds_between_actions: 60 + max_actions_per_hour: 20 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a25dca4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +aiohttp +pyyaml +zeroconf +python-dotenv +ifaddr +qrcode diff --git a/solixauto.py b/solixauto.py new file mode 100755 index 0000000..0adc5e2 --- /dev/null +++ b/solixauto.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from solixauto.cli import main + +if __name__ == "__main__": + main() diff --git a/solixauto/__init__.py b/solixauto/__init__.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/solixauto/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/solixauto/anker.py b/solixauto/anker.py new file mode 100644 index 0000000..cd2ef5b --- /dev/null +++ b/solixauto/anker.py @@ -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 " + ) + + 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 diff --git a/solixauto/cli.py b/solixauto/cli.py new file mode 100644 index 0000000..aef5819 --- /dev/null +++ b/solixauto/cli.py @@ -0,0 +1,1510 @@ +import argparse +import asyncio +import ipaddress +import os +from datetime import datetime +import platform +import shutil +import sys + +from . import notify, paths, shelly, templates +from .engine import Engine, Reporter, configure_event_loop, dry_run_report +from .profiles import list_profiles, load_yaml +from .rules import PowerProfile, ProfileError, format_duration, validate + + +def fail(message): + print(f"error: {message}", file=sys.stderr) + sys.exit(1) + + +def cmd_setup(args): + from . import setup as setup_module + + def wrapped_confirm(question, default=False): + return confirm(question, default) + + try: + ok = setup_module.run_setup(prompt, choose, wrapped_confirm) + except KeyboardInterrupt: + print() + print() + print("Setup stopped. Nothing was left half-finished.") + print(f"Run it again any time: {paths.command('setup')}") + sys.exit(1) + + sys.exit(0 if ok else 1) + + +def cmd_doctor(args): + from . import notify as notify_module + + print() + print("Environment") + print(f" platform {platform.system()} {platform.release()} ({sys.platform})") + print(f" python {platform.python_version()} at {sys.executable}") + + problems = [] + warnings = [] + + if sys.version_info < (3, 9): + problems.append(f"Python {platform.python_version()} is too old, need 3.9+") + + print() + print("Dependencies") + for module, package, required in ( + ("yaml", "pyyaml", True), + ("aiohttp", "aiohttp", True), + ("anker_solix_api", "anker-solix-api", True), + ("zeroconf", "zeroconf", False), + ("dotenv", "python-dotenv", False), + ("ifaddr", "ifaddr", False), + ("qrcode", "qrcode", False), + ): + try: + __import__(module) + print(f" {package:<18} ok") + except ImportError: + label = "MISSING" if required else "optional, not installed" + print(f" {package:<18} {label}") + if required: + problems.append(f"{package} is not installed") + else: + warnings.append(f"{package} is not installed") + + print() + print("Directories") + paths.ensure_dirs() + print(f" root {paths.BASE_DIR}") + if paths.permissions_enforced(): + print(" permissions POSIX mode bits applied to secret files") + else: + print(" permissions Windows, mode bits not enforced") + warnings.append( + "on Windows, notifications.yaml and .env are not protected by file " + "permissions. Keep this directory out of shared or synced folders." + ) + + print() + print("Credentials") + try: + from .credentials import load_credentials + + user, _, country = load_credentials() + masked = user[:2] + "***" + user[-6:] if len(user) > 8 else "***" + print(f" anker found ({masked}, country {country})") + except Exception as err: + print(" anker not found") + problems.append( + "Anker credentials not found. Set ANKERUSER and ANKERPASSWORD, or " + f"put a .env file in {paths.BASE_DIR}" + ) + + print() + print("Network") + try: + from .shelly import local_subnets + + subnets = local_subnets() + if subnets: + for network in subnets: + print(f" subnet {network}") + else: + print(" subnet could not detect one") + warnings.append( + "no local subnet detected. Use discover-shelly --network or --host." + ) + except Exception as err: + print(f" subnet error: {err}") + + print() + print("Notifications") + backend = notify_module.desktop_backend() + print(f" desktop {backend or 'no backend available'}") + if backend is None and sys.platform.startswith("linux"): + warnings.append( + "desktop notifications need libnotify-bin on Linux " + "(apt install libnotify-bin)" + ) + enabled = notify_module.enabled_channels() + print(f" channels {', '.join(enabled) if enabled else 'none enabled'}") + + print() + for warning in warnings: + print(f" warning: {warning}") + for problem in problems: + print(f" PROBLEM: {problem}") + + print() + print("Invocation") + print(f" this run {paths.invocation()}") + if paths.invocation() != "solixauto": + print() + print(" To type just 'solixauto' from anywhere, add an alias:") + shell_file = "~/.zshrc" if "zsh" in os.environ.get("SHELL", "") else "~/.bashrc" + script = os.path.abspath(sys.argv[0]) if sys.argv else "solixauto.py" + print(f" echo 'alias solixauto=\"{sys.executable} {script}\"' >> {shell_file}") + print(f" source {shell_file}") + + print() + if problems: + print(f"{len(problems)} problem(s) found.") + sys.exit(1) + print("Ready.") + + +def cmd_init(args): + base = paths.ensure_dirs() + templates.scaffold_readme() + print(f"Initialized {base}") + for directory in paths.ALL_DIRS[1:]: + print(f" {paths.relative(directory)}/") + print(f" {paths.relative(paths.POWER_PROFILE_DIR / 'README.md')}") + + +def cmd_discover_anker(args): + from . import anker + + written = asyncio.run( + anker.discover( + settle=args.settle, + only_pn=args.pn, + only_sn=args.sn, + skip=args.skip, + include_offline=args.include_offline, + ) + ) + print() + print(f"Wrote {len(written)} Anker device profile(s).") + + +def cmd_discover_shelly(args): + network = None + if args.network: + try: + network = ipaddress.ip_network(args.network, strict=False) + except ValueError as err: + fail(f"invalid network {args.network!r}: {err}") + + written = asyncio.run( + shelly.discover( + hosts=args.host, + network=network, + use_mdns=not args.no_mdns, + ) + ) + print() + print(f"Wrote {len(written)} Shelly device profile(s).") + if not written: + print(f"Nothing found. Try: {paths.command('discover-shelly --host 192.168.1.50')}") + + +def cmd_name(args): + from .profiles import save_yaml, slugify + + path = paths.resolve_profile(args.device, None) + if path is None: + fail(f"no device profile matching {args.device!r}") + + data = load_yaml(path) + identity = data.setdefault("identity", {}) + old_name = identity.get("name") or "" + new_name = args.name.strip() + + if not new_name: + fail("name cannot be empty") + + identity["name"] = new_name + + aliases = list(data.get("aliases") or []) + for candidate in (new_name, old_name, path.stem): + if candidate and candidate not in aliases: + aliases.append(candidate) + data["aliases"] = aliases + + destination = path.parent / f"{slugify(new_name).lower()}.yaml" + + if destination != path and destination.exists(): + fail(f"{destination.name} already exists. Pick another name.") + + save_yaml(path, data) + + if destination != path: + path.replace(destination) + print(f"renamed {path.name} -> {destination.name}") + print(f"{destination.name} is now named {new_name!r}") + print(f"resolves as: {', '.join(aliases)}") + + if args.on_device: + if data.get("kind") != "shelly": + fail("--on-device only applies to Shelly devices") + asyncio.run(_push_shelly_name(data, new_name)) + + +async def _push_shelly_name(data, new_name): + import aiohttp + from .shelly import auth_from, CONTROL_TIMEOUT + + host = (data.get("access") or {}).get("host") + generation = int((data.get("identity") or {}).get("generation", 1) or 1) + + if generation < 2: + fail("writing the name on device is only supported for Gen2+ Shellys") + + url = f"http://{host}/rpc/Sys.SetConfig" + payload = {"config": {"device": {"name": new_name}}} + + async with aiohttp.ClientSession() as session: + try: + async with session.post( + url, + json=payload, + timeout=aiohttp.ClientTimeout(total=CONTROL_TIMEOUT), + auth=auth_from(data), + ) as response: + body = await response.text() + if response.status != 200: + fail(f"HTTP {response.status} from {host}: {body}") + except Exception as err: + fail(f"could not write name to {host}: {type(err).__name__}: {err}") + + print(f"wrote the name to the device at {host}") + + +def confirm(question, default=False): + suffix = "[y/N]" if not default else "[Y/n]" + answer = _read(f"{question} {suffix}: ").lower() + if not answer: + return default + return answer in ("y", "yes") + + +def cmd_service(args): + from . import service + + profile = load_power_profile(args.profile) + name = profile.path.stem + kind = service.platform_kind() + + if kind == "unknown": + fail(f"no service manager known for {sys.platform}") + + if args.show: + content = service.render(name) + print() + print(content) + return + + if args.status: + running, detail = service.status(name) + print() + print(f"{name}: {detail}") + target = service.unit_path(name) + if target: + print(f" unit: {target}") + print(f" log: {paths.ENGINE_LOG}") + sys.exit(0 if running else 1) + + if args.uninstall: + ok, detail = service.uninstall(name) + print() + print(detail if detail else "removed") + print(f"{name} will no longer start automatically.") + print("Note: the Shelly stays in whatever state it was last set to.") + return + + if kind == "windows": + print() + print(service.windows_instructions(name)) + return + + target = service.unit_path(name) + + print() + print(f"This will install a background service for {name}:") + print() + print(f" unit file {target}") + print(f" runs {sys.executable} {service.script_path()} run {name}") + print(f" log {paths.ENGINE_LOG}") + print() + print("It starts at login, restarts if it crashes, and will switch your") + print("Shelly without anyone watching.") + print() + + env_file = service.env_file_in_use() + if env_file: + print(f" credentials {env_file}") + else: + print(" WARNING: no .env file found. The service will fail to") + print(" authenticate unless ANKERUSER and ANKERPASSWORD are set") + print(" in the environment it inherits.") + print() + + if not args.yes and not confirm("Install and start it now?"): + print("Not installed.") + return + + ok, detail = service.install(name) + print() + if not ok: + print(f"Install failed: {detail}") + sys.exit(1) + + print("Installed and started.") + print() + running, state = service.status(name) + print(f" status: {state}") + print(f" log: {paths.ENGINE_LOG}") + print() + print(f"Stop and remove it with: {paths.command(f'service {name} --uninstall')}") + + if sys.platform == "darwin": + print() + print("If this Mac sleeps, the automation stops with it. For an always-on") + print("machine set System Settings > Energy Saver to prevent sleep.") + + +def cmd_conflicts(args): + from .shelly import ( + ShellyTarget, + automation_warnings, + clear_auto_timer, + delete_schedule, + delete_webhook, + set_initial_state, + ) + import aiohttp + + path = paths.resolve_profile(args.device, "shelly") + if path is None: + fail(f"no Shelly device profile matching {args.device!r}") + + target = ShellyTarget(path, args.channel) + + async def go(): + async with aiohttp.ClientSession() as session: + automation = await target.automation(session) + if not automation: + fail(f"could not reach {target.host}") + + print() + print(f"{target.label} channel {target.channel}") + print() + + schedules = automation.get("schedules") or [] + print(f"Schedules on the device ({len(schedules)}):") + if not schedules: + print(" none") + for job in schedules: + mark = "" if job.get("enabled") else " [disabled]" + print(f" {job['description']}{mark}") + + timers = automation.get("timers") or {} + print() + print(f"Auto timers and power-up state ({len(timers)}):") + if not timers: + print(" none") + for index, values in sorted(timers.items()): + for key, value in values.items(): + print(f" channel {index}: {key} = {value}") + + hooks = automation.get("webhooks") or [] + print() + print(f"Webhooks and actions ({len(hooks)}):") + if not hooks: + print(" none") + for hook in hooks: + label = hook.get("name") or hook.get("event") + mark = "" if hook.get("enabled") else " [disabled]" + print(f" {label}{mark}") + + warnings = automation_warnings(automation, target.channel) + print() + + if not warnings: + print("Nothing on the device will fight your rules.") + print() + print("Note: schedules created as Shelly Cloud 'scenes' live in the") + print("cloud, not on the device, and cannot be seen from here.") + return + + print(f"{len(warnings)} of these will conflict with your rules:") + for item in warnings: + print(f" {item}") + + if not args.fix: + print() + print("Rerun with --fix to remove them from here, or change them") + print("in the Shelly app.") + print() + print("Note: schedules created as Shelly Cloud 'scenes' live in the") + print("cloud, not on the device, and cannot be seen from here.") + return + + if target.generation < 2: + fail("--fix currently supports Gen2 and newer Shellys only") + + print() + print("=" * 60) + print("These changes are made on the device and cannot be undone from") + print("here. You would have to recreate them in the Shelly app.") + print("=" * 60) + + changed = 0 + + for job in schedules: + if not job.get("enabled") or job.get("id") is None: + continue + print() + print(f" {job['description']}") + if not (args.yes or confirm(" Delete this schedule?")): + print(" kept") + continue + if await delete_schedule( + session, target.host, job["id"], target.generation, target.auth + ): + print(" deleted") + changed += 1 + else: + print(" FAILED to delete") + + for index, values in sorted(timers.items()): + if str(index) != str(target.channel): + continue + for key, value in values.items(): + print() + if key == "initial_state": + if str(value).lower() == "on": + continue + print(f" channel {index} powers up to '{value}'") + print(" Recommended: 'on', so a power cut cannot leave") + print(" whatever this plug charges stranded off.") + if not ( + args.yes or confirm(" Set power-up state to ON?") + ): + print(" kept") + continue + if await set_initial_state( + session, target.host, index, "on", + target.generation, target.auth, + ): + print(" set to on") + changed += 1 + else: + print(" FAILED to change") + else: + print(f" channel {index} has {key} = {value}") + if not (args.yes or confirm(f" Disable {key}?")): + print(" kept") + continue + if await clear_auto_timer( + session, target.host, index, key, + target.generation, target.auth, + ): + print(" disabled") + changed += 1 + else: + print(" FAILED to change") + + for hook in hooks: + if not hook.get("enabled") or hook.get("id") is None: + continue + label = hook.get("name") or hook.get("event") + print() + print(f" webhook: {label}") + if not (args.yes or confirm(" Delete this webhook?")): + print(" kept") + continue + if await delete_webhook( + session, target.host, hook["id"], target.generation, target.auth + ): + print(" deleted") + changed += 1 + else: + print(" FAILED to delete") + + print() + if not changed: + print("Nothing changed.") + return + + print(f"{changed} change(s) applied. Re-checking the device...") + after = await target.automation(session) + remaining = automation_warnings(after, target.channel) + print() + if remaining: + print(f"{len(remaining)} conflict(s) remain:") + for item in remaining: + print(f" {item}") + else: + print("Device is clean. Nothing left that will fight your rules.") + + print() + print("Re-run discover-shelly to refresh the stored profile.") + + asyncio.run(go()) + + +def cmd_status(args): + from .anker import AnkerSource + from .rules import derived_values + + path = paths.resolve_profile(args.device, "anker") + if path is None: + fail(f"no Anker device profile matching {args.device!r}") + + profile = load_yaml(path) + identity = profile.get("identity", {}) + label = identity.get("model") or identity.get("part_number") or "device" + + source = AnkerSource(path) + + def show(values, age): + keys = sorted(values) + if args.fields: + wanted = [f.strip().lower() for f in args.fields] + keys = [k for k in keys if any(w in k.lower() for w in wanted)] + if not keys: + print(" (no fields matched)") + return + + derived_names = set((profile.get("derived") or {}).keys()) + width = max(len(k) for k in keys) + for key in keys: + marker = " <- derived" if key in derived_names else "" + print(f" {key.ljust(width)} {values[key]}{marker}") + if age is not None: + print() + print(f" last message {age:.0f}s ago") + + async def go(): + print(f"Connecting to {label} {source.serial}...") + try: + await source.start(settle=args.settle) + except RuntimeError as err: + fail(str(err)) + + try: + while True: + status = source.read() + if not status: + from .anker import OFFLINE_HINT + + print("no telemetry decoded yet") + print(OFFLINE_HINT) + else: + values = derived_values(profile, status) + print() + print(datetime.now().strftime("%H:%M:%S")) + show(values, source.age_seconds()) + + if not args.watch: + break + await asyncio.sleep(args.interval) + finally: + await source.stop() + + try: + asyncio.run(go()) + except KeyboardInterrupt: + print() + print("stopped") + + +def cmd_switch(args): + from .shelly import ShellyTarget + import aiohttp + + path = paths.resolve_profile(args.device, "shelly") + if path is None: + fail(f"no Shelly device profile matching {args.device!r}") + + target = ShellyTarget(path, args.channel) + + async def go(): + async with aiohttp.ClientSession() as session: + current = await target.get_state(session) + if current is None: + fail( + f"could not read {target.host}. Check that it is powered on, " + "on the network, and that access.host in its profile is right." + ) + + print(f"{target.label} channel {target.channel} is currently " + f"{'ON' if current else 'OFF'}") + + action = args.action.lower() + if action == "status": + return + + if action == "toggle": + desired = not current + else: + desired = action == "on" + + if desired == current and action != "toggle": + print(f"already {'ON' if desired else 'OFF'}, nothing to do") + return + + print(f"turning {'ON' if desired else 'OFF'}...") + try: + await target.set_state(session, desired) + except Exception as err: + fail(f"{type(err).__name__}: {err}") + + await asyncio.sleep(1.0) + confirmed = await target.get_state(session) + if confirmed is None: + print("command sent but the state could not be read back") + elif confirmed == desired: + print(f"confirmed {'ON' if confirmed else 'OFF'}") + else: + print( + f"WARNING: commanded {'ON' if desired else 'OFF'} but device " + f"reports {'ON' if confirmed else 'OFF'}" + ) + + asyncio.run(go()) + + +def cmd_devices(args): + paths.ensure_dirs() + + for label, directory in ( + ("Anker", paths.ANKER_PROFILE_DIR), + ("Shelly", paths.SHELLY_PROFILE_DIR), + ): + found = list_profiles(directory) + print() + print(f"{label} ({len(found)}):") + if not found: + print(" none. Run the matching discover command.") + continue + for path in found: + try: + data = load_yaml(path) + except Exception as err: + print(f" {path.name} [unreadable: {err}]") + continue + identity = data.get("identity", {}) + friendly = identity.get("name") or "(unnamed)" + descriptor = " ".join( + str(part) + for part in ( + identity.get("model"), + identity.get("serial") or identity.get("id"), + ) + if part + ) + extra = "" + if data.get("kind") == "shelly": + extra = f" host {data.get('access', {}).get('host')}" + elif data.get("kind") == "anker": + extra = f" {len(data.get('readable') or {})} fields" + print(f" {friendly}") + print(f" file {path.name}") + print(f" {descriptor}{extra}") + channels = data.get("channels") or {} + if len(channels) > 1: + labels = ", ".join( + f"{index}={entry.get('name')}" for index, entry in sorted(channels.items()) + ) + print(f" channels {labels}") + + +def cmd_fields(args): + path = paths.resolve_profile(args.device, "anker") + if path is None: + fail(f"no Anker device profile matching {args.device!r}") + + data = load_yaml(path) + readable = data.get("readable") or {} + derived = data.get("derived") or {} + + query = (args.filter or "").lower() + + print() + print(f"{path.name}") + print() + print(f"derived ({len(derived)}):") + for name, spec in sorted(derived.items()): + if query and query not in name.lower(): + continue + expression = spec.get("expression") if isinstance(spec, dict) else spec + description = spec.get("description", "") if isinstance(spec, dict) else "" + print(f" {name:<26} = {expression}") + if description: + print(f" {'':<26} {description}") + + print() + print(f"readable ({len(readable)}):") + for name, spec in sorted(readable.items()): + if query and query not in name.lower(): + continue + kind = spec.get("type") if isinstance(spec, dict) else "?" + sample = spec.get("sample") if isinstance(spec, dict) else spec + print(f" {name:<26} {kind:<6} sample={sample!r}") + + if args.writable: + writable = data.get("writable") or {} + print() + print(f"writable ({len(writable)}) - reference only, the engine never calls these:") + for name, spec in sorted(writable.items()): + params = ", ".join(spec.get("parameters", [])) if isinstance(spec, dict) else "" + print(f" {name:<26} ({params})") + + +def _read(question, secret=False): + import getpass + + try: + if secret: + return getpass.getpass(question).strip() + return input(question).strip() + except EOFError: + print() + print() + print("No input available. Run this from an interactive terminal.") + sys.exit(1) + + +def prompt(label, default=None, secret=False): + suffix = f" [{default}]" if default else "" + while True: + value = _read(f"{label}{suffix}: ", secret) + if value: + return value + if default is not None: + return default + print(" required") + + +def choose(options, label="Choose"): + for index, (key, description) in enumerate(options, start=1): + print(f" {index}) {description}") + print() + while True: + raw = _read(f"{label} [1-{len(options)}]: ") + if raw.isdigit() and 1 <= int(raw) <= len(options): + return options[int(raw) - 1][0] + print(" pick a number from the list") + + +def setup_ntfy(args): + from . import notify as notify_module + + topic = args.topic or notify_module.generate_topic() + url = notify_module.subscribe_url(topic) + + print() + print("ntfy is free, open source, and needs no account.") + print() + + if not args.no_qr: + platform_choice = choose( + [ + ("ios", "iPhone or iPad"), + ("android", "Android"), + ("installed", "I already have the ntfy app installed"), + ("skip", "Skip, I will set it up later"), + ], + "Which phone will receive the alerts", + ) + else: + platform_choice = "installed" + + if platform_choice == "ios": + print() + print("STEP 1 - install the app") + print() + notify_module.show_qr( + notify_module.NTFY_IOS_URL, + "Point your camera at this to open the App Store:", + dark_terminal=not args.light_terminal, + ) + print() + input("Press Enter once the app is installed...") + elif platform_choice == "android": + print() + print("STEP 1 - install the app") + print() + notify_module.show_qr( + notify_module.NTFY_ANDROID_URL, + "Point your camera at this to open Google Play:", + dark_terminal=not args.light_terminal, + ) + print() + print(f" F-Droid instead: {notify_module.NTFY_FDROID_URL}") + print() + input("Press Enter once the app is installed...") + + print() + print("STEP 2 - subscribe to your private topic") + print() + print("A random topic has been generated for you. The topic IS the") + print("credential, which is why it is long. Anyone who knows it can read") + print("your alerts, so do not share or post it.") + print() + + notify_module.show_qr( + url, + "Scan this to open the topic directly in ntfy:", + dark_terminal=not args.light_terminal, + ) + + print() + print(" If the code will not scan, your terminal may use a light") + print(" background. Rerun with --light-terminal to flip it.") + print() + print(" Or add it by hand in the app:") + print(" tap +, then 'Subscribe to topic'") + print(f" topic: {topic}") + print(" server: ntfy.sh (leave as default)") + print() + print(f" Docs: {notify_module.NTFY_DOCS_URL}") + print() + input("Press Enter once you have subscribed...") + + return "ntfy", {"enabled": True, "topic": topic} + + +def setup_desktop(args): + from . import notify as notify_module + + backend = notify_module.desktop_backend() + print() + if backend: + print(f"Desktop notifications will use: {backend}") + else: + print("No desktop notification backend was found on this machine.") + if sys.platform.startswith("linux"): + print("Install libnotify-bin, then rerun.") + return None, None + print("These only appear on this machine, not on your phone.") + return "desktop", {"enabled": True} + + +def setup_pushover(args): + print() + print("Create an app at https://pushover.net to get an API token.") + print("Your user key is on the dashboard after you log in.") + print() + user_key = prompt("User key") + api_token = prompt("API token", secret=True) + return "pushover", { + "enabled": True, + "user_key": user_key, + "api_token": api_token, + } + + +def setup_telegram(args): + print() + print("Message @BotFather on Telegram to create a bot and get its token.") + print("Then message your new bot once, and read your chat id from:") + print(" https://api.telegram.org/bot/getUpdates") + print() + token = prompt("Bot token", secret=True) + chat_id = prompt("Chat id") + return "telegram", {"enabled": True, "bot_token": token, "chat_id": chat_id} + + +def setup_email(args): + print() + print("For Gmail you must use an App Password, not your normal password.") + print() + host = prompt("SMTP host", "smtp.gmail.com") + port = prompt("SMTP port", "587") + username = prompt("Username") + password = prompt("Password", secret=True) + sender = prompt("From address", username) + recipients = prompt("To address(es), comma separated", username) + return "email", { + "enabled": True, + "host": host, + "port": port, + "username": username, + "password": password, + "sender": sender, + "recipients": [r.strip() for r in recipients.split(",") if r.strip()], + } + + +def setup_webhook(args): + print() + print("Works with Slack or Discord incoming webhooks, or any JSON endpoint.") + print() + url = prompt("Webhook URL") + style = choose( + [ + ("json", "generic JSON"), + ("slack", "Slack"), + ("discord", "Discord"), + ("form", "form encoded"), + ], + "Format", + ) + return "webhook", {"enabled": True, "url": url, "format": style} + + +SETUP_FLOWS = { + "ntfy": setup_ntfy, + "desktop": setup_desktop, + "pushover": setup_pushover, + "telegram": setup_telegram, + "email": setup_email, + "webhook": setup_webhook, +} + + +def cmd_notify_setup(args): + from . import notify as notify_module + + path = notify_module.ensure_config() + enabled = notify_module.enabled_channels() + + if args.show: + print(f"Config: {path}") + print() + print(f"Enabled: {', '.join(enabled) if enabled else 'none'}") + return + + print() + print("Notification setup") + print(f" config file: {path}") + if enabled: + print(f" already enabled: {', '.join(enabled)}") + print() + + channel = args.channel + if not channel: + print("Which channel do you want to set up?") + print() + channel = choose( + [ + ("ntfy", "ntfy - free push to your phone, no account (recommended)"), + ("desktop", "desktop - notification on this machine only"), + ("pushover", "pushover - paid push, very reliable delivery"), + ("telegram", "telegram - free, via a bot"), + ("email", "email - SMTP"), + ("webhook", "webhook - Slack, Discord, or custom"), + ] + ) + + flow = SETUP_FLOWS.get(channel) + if flow is None: + fail(f"unknown channel {channel!r}") + + name, values = flow(args) + if name is None: + fail("setup did not complete") + + missing = notify_module.apply_settings(name, values) + if missing: + print() + print(f"warning: could not write {', '.join(missing)} into the config.") + print(f"Edit {path} by hand for those.") + + print() + print(f"Enabled {name} in {paths.relative(path)}") + + if args.no_test: + print("Skipping the test send.") + return + + print("Sending a test notification...") + try: + results = asyncio.run(notify_module.send_test(name)) + except ValueError as err: + fail(str(err)) + + print() + outcome = results.get(name, "unknown") + if outcome == "ok": + print(f" {name}: sent") + print() + print("Check your device. If nothing arrived, confirm you subscribed to") + print(f"the exact topic, then run: {paths.command('notify-test')}") + else: + print(f" {name}: {outcome}") + print() + print(f"Fix the settings in {path} and run: {paths.command('notify-test')}") + sys.exit(1) + + +def cmd_notify_qr(args): + from . import notify as notify_module + + config = notify_module.load_config() + settings = config.get("ntfy") or {} + topic = settings.get("topic") + + if not topic or "CHANGE-ME" in str(topic): + fail(f"no ntfy topic configured. Run: {paths.command('notify-setup --channel ntfy')}") + + server = settings.get("server") or "https://ntfy.sh" + url = notify_module.subscribe_url(topic, server) + + print() + if args.store: + target = ( + notify_module.NTFY_IOS_URL + if args.store == "ios" + else notify_module.NTFY_ANDROID_URL + ) + notify_module.show_qr( + target, + f"Install the ntfy app ({args.store}):", + dark_terminal=not args.light_terminal, + ) + print() + + notify_module.show_qr( + url, + "Scan to subscribe on another device:", + dark_terminal=not args.light_terminal, + ) + print() + print(f" topic: {topic}") + print(f" server: {server}") + if not settings.get("enabled"): + print() + print(" note: ntfy is configured but not enabled in notifications.yaml") + + +def cmd_notify_test(args): + try: + results = asyncio.run(notify.send_test(args.channel, args.message)) + except ValueError as err: + fail(str(err)) + + print() + failures = 0 + for name, outcome in sorted(results.items()): + print(f" {name:<10} {outcome}") + if outcome != "ok": + failures += 1 + print() + if failures: + print(f"{failures} channel(s) failed.") + sys.exit(1) + print("Sent. Check your device.") + + +def cmd_new_profile(args): + try: + destination = templates.create( + args.name, + source=args.source, + target=args.target, + channel=args.channel, + template=args.template, + force=args.force, + ) + except FileExistsError as err: + fail(f"{err} already exists. Use --force to overwrite.") + + print(f"Created {paths.relative(destination)}") + print() + print("Next:") + print(f" 1. edit it and set source/target if the defaults were placeholders") + print(f" 2. {paths.command(f'run {args.name} --test')}") + + +def cmd_profiles(args): + paths.ensure_dirs() + found = [ + path + for path in list_profiles(paths.POWER_PROFILE_DIR) + if path.name != "README.md" + ] + + print() + print(f"Power profiles ({len(found)}):") + if not found: + print(f" none. Run: {paths.command('new-profile ')}") + return + + for path in found: + try: + profile = PowerProfile(path) + except Exception as err: + print(f" {path.name} [invalid: {err}]") + continue + problems, _ = validate(profile) + marker = "OK" if not problems else f"{len(problems)} problem(s)" + state = "enabled" if profile.enabled else "disabled" + print(f" {path.name}") + print( + f" {state}, {len(profile.active_rules())} rule(s), " + f"poll {format_duration(profile.poll_interval)}, {marker}" + ) + + +def load_power_profile(reference): + path = paths.resolve_profile(reference, "power") + if path is None: + fail(f"no power profile matching {reference!r}") + try: + return PowerProfile(path) + except ProfileError as err: + fail(f"{path.name}: {err}") + except Exception as err: + fail(f"{path.name}: {type(err).__name__}: {err}") + + +def cmd_validate(args): + profile = load_power_profile(args.profile) + problems, notes = validate(profile) + + print() + print(f"{profile.path.name}") + for note in notes: + print(f" note: {note}") + for problem in problems: + print(f" PROBLEM: {problem}") + if not problems: + print(f" valid, {len(profile.active_rules())} active rule(s)") + sys.exit(1 if problems else 0) + + +def parse_overrides(items): + if not items: + return {} + overrides = {} + for item in items: + if "=" not in item: + fail(f"--simulate expects FIELD=VALUE, got {item!r}") + field, _, raw = item.partition("=") + raw = raw.strip() + try: + value = int(raw) + except ValueError: + try: + value = float(raw) + except ValueError: + lowered = raw.lower() + if lowered in ("true", "false"): + value = lowered == "true" + else: + value = raw + overrides[field.strip()] = value + return overrides + + +def cmd_run(args): + profile = load_power_profile(args.profile) + + if args.test: + overrides = parse_overrides(args.simulate) + ok = asyncio.run( + dry_run_report( + profile, + cycles=args.cycles, + offline=args.offline, + overrides=overrides, + ) + ) + sys.exit(0 if ok else 1) + + problems, notes = validate(profile) + for note in notes: + print(f"note: {note}") + if problems: + for problem in problems: + print(f"PROBLEM: {problem}", file=sys.stderr) + fail("profile has problems. Run with --test for detail.") + + if not profile.enabled: + fail(f"{profile.path.name} has enabled: false") + + paths.ensure_dirs() + reporter = Reporter(log_path=paths.ENGINE_LOG, quiet=args.quiet) + reporter(f"starting {profile.name}", force=True) + + engine = Engine(profile, dry_run=args.dry_run, reporter=reporter) + + try: + asyncio.run(engine.run(cycles=args.cycles if args.cycles else None)) + except KeyboardInterrupt: + reporter("stopped by user", force=True) + except Exception as err: + reporter(f"fatal: {type(err).__name__}: {err}", force=True) + sys.exit(1) + finally: + reporter.close() + + +def build_parser(): + parser = argparse.ArgumentParser( + prog="solixauto", + description=( + "Anker SOLIX telemetry to Shelly switch automation. " + "Anker devices are read-only data sources; Shelly devices are the " + "only things ever switched." + ), + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + setup_parser = subparsers.add_parser( + "setup", help="guided end-to-end setup, start here" + ) + setup_parser.set_defaults(func=cmd_setup) + + init_parser = subparsers.add_parser("init", help="create the directory layout") + init_parser.set_defaults(func=cmd_init) + + doctor_parser = subparsers.add_parser( + "doctor", help="check this machine is set up correctly" + ) + doctor_parser.set_defaults(func=cmd_doctor) + + anker_parser = subparsers.add_parser( + "discover-anker", help="connect to Anker cloud and save device profiles" + ) + anker_parser.add_argument("--sn", help="only this serial number") + anker_parser.add_argument("--pn", help="only this part number, e.g. A1782") + anker_parser.add_argument( + "--settle", type=int, default=45, help="seconds to wait for telemetry per device" + ) + anker_parser.add_argument( + "--skip", + action="append", + metavar="SERIAL", + help="never try this serial, repeatable. Use for Bluetooth-only devices.", + ) + anker_parser.add_argument( + "--include-offline", + action="store_true", + help="try devices the cloud reports as not connected", + ) + anker_parser.set_defaults(func=cmd_discover_anker) + + shelly_parser = subparsers.add_parser( + "discover-shelly", help="find Shelly devices on the local network" + ) + shelly_parser.add_argument( + "--host", action="append", help="probe this address directly, repeatable" + ) + shelly_parser.add_argument("--network", help="scan this CIDR, e.g. 192.168.1.0/24") + shelly_parser.add_argument( + "--no-mdns", action="store_true", help="skip mDNS discovery" + ) + shelly_parser.set_defaults(func=cmd_discover_shelly) + + devices_parser = subparsers.add_parser("devices", help="list saved device profiles") + devices_parser.set_defaults(func=cmd_devices) + + name_parser = subparsers.add_parser( + "name", help="give a device profile a friendly name and rename its file" + ) + name_parser.add_argument("device", help="current profile name, serial, MAC or IP") + name_parser.add_argument("name", help="the friendly name to use") + name_parser.add_argument( + "--on-device", + action="store_true", + help="also write this name to the Shelly itself over local HTTP", + ) + name_parser.set_defaults(func=cmd_name) + + status_parser = subparsers.add_parser( + "status", help="read live telemetry from an Anker device" + ) + status_parser.add_argument("device", help="Anker profile name or path") + status_parser.add_argument( + "--fields", nargs="+", help="only show fields containing these substrings" + ) + status_parser.add_argument( + "--watch", action="store_true", help="keep refreshing until Ctrl-C" + ) + status_parser.add_argument("--interval", type=float, default=5.0) + status_parser.add_argument("--settle", type=int, default=45) + status_parser.set_defaults(func=cmd_status) + + service_parser = subparsers.add_parser( + "service", help="run a power profile in the background at login" + ) + service_parser.add_argument("profile") + service_parser.add_argument( + "--show", action="store_true", help="print the unit file without installing" + ) + service_parser.add_argument( + "--status", action="store_true", help="report whether it is installed/running" + ) + service_parser.add_argument( + "--uninstall", action="store_true", help="stop and remove the service" + ) + service_parser.add_argument( + "--yes", action="store_true", help="do not ask before installing" + ) + service_parser.set_defaults(func=cmd_service) + + conflicts_parser = subparsers.add_parser( + "conflicts", help="show schedules and timers configured on a Shelly itself" + ) + conflicts_parser.add_argument("device", help="Shelly profile name or path") + conflicts_parser.add_argument("--channel", type=int, default=None) + conflicts_parser.add_argument( + "--fix", + action="store_true", + help="offer to remove each conflict, asking before every change", + ) + conflicts_parser.add_argument( + "--yes", + action="store_true", + help="with --fix, apply every change without asking", + ) + conflicts_parser.set_defaults(func=cmd_conflicts) + + switch_parser = subparsers.add_parser( + "switch", help="manually control a Shelly, to verify wiring before automating" + ) + switch_parser.add_argument("device", help="Shelly profile name or path") + switch_parser.add_argument( + "action", choices=["on", "off", "toggle", "status"], nargs="?", default="status" + ) + switch_parser.add_argument("--channel", type=int, default=None) + switch_parser.set_defaults(func=cmd_switch) + + fields_parser = subparsers.add_parser( + "fields", help="list the field names usable in rules" + ) + fields_parser.add_argument("device", help="Anker profile name or path") + fields_parser.add_argument("--filter", help="only names containing this text") + fields_parser.add_argument( + "--writable", action="store_true", help="also show control methods" + ) + fields_parser.set_defaults(func=cmd_fields) + + new_parser = subparsers.add_parser("new-profile", help="scaffold a power profile") + new_parser.add_argument("name") + new_parser.add_argument("--source", help="Anker profile filename") + new_parser.add_argument("--target", help="Shelly profile filename") + new_parser.add_argument("--channel", type=int, default=0) + new_parser.add_argument( + "--template", + choices=sorted(templates.RULE_SETS), + default="solar", + ) + new_parser.add_argument("--force", action="store_true") + new_parser.set_defaults(func=cmd_new_profile) + + notify_setup_parser = subparsers.add_parser( + "notify-setup", help="interactively set up and test a notification channel" + ) + notify_setup_parser.add_argument( + "--channel", + choices=["ntfy", "desktop", "pushover", "telegram", "email", "webhook"], + help="skip the menu and set up this channel", + ) + notify_setup_parser.add_argument( + "--topic", help="use this ntfy topic instead of generating one" + ) + notify_setup_parser.add_argument( + "--no-test", action="store_true", help="do not send a test notification" + ) + notify_setup_parser.add_argument( + "--no-qr", action="store_true", help="do not draw QR codes" + ) + notify_setup_parser.add_argument( + "--light-terminal", + action="store_true", + help="flip QR colours for a light background terminal", + ) + notify_setup_parser.add_argument( + "--show", action="store_true", help="just print what is configured" + ) + notify_setup_parser.set_defaults(func=cmd_notify_setup) + + notify_qr_parser = subparsers.add_parser( + "notify-qr", help="show the QR code for your ntfy topic again" + ) + notify_qr_parser.add_argument( + "--store", + choices=["ios", "android"], + help="also show a QR for the app store", + ) + notify_qr_parser.add_argument( + "--light-terminal", + action="store_true", + help="flip QR colours for a light background terminal", + ) + notify_qr_parser.set_defaults(func=cmd_notify_qr) + + notify_test_parser = subparsers.add_parser( + "notify-test", help="send a test notification" + ) + notify_test_parser.add_argument( + "--channel", help="only this channel, e.g. ntfy" + ) + notify_test_parser.add_argument("--message", help="custom test message") + notify_test_parser.set_defaults(func=cmd_notify_test) + + profiles_parser = subparsers.add_parser("profiles", help="list power profiles") + profiles_parser.set_defaults(func=cmd_profiles) + + validate_parser = subparsers.add_parser( + "validate", help="check a power profile without connecting" + ) + validate_parser.add_argument("profile") + validate_parser.set_defaults(func=cmd_validate) + + run_parser = subparsers.add_parser("run", help="run a power profile") + run_parser.add_argument("profile") + run_parser.add_argument( + "-t", + "--test", + action="store_true", + help="validate and show what would happen, without switching anything", + ) + run_parser.add_argument( + "--offline", + action="store_true", + help="with --test, evaluate against saved samples instead of connecting", + ) + run_parser.add_argument( + "--dry-run", + action="store_true", + help="run the full loop but never send a switch command", + ) + run_parser.add_argument( + "--cycles", type=int, default=0, help="stop after N evaluations" + ) + run_parser.add_argument("--quiet", action="store_true") + run_parser.add_argument( + "--simulate", + nargs="+", + metavar="FIELD=VALUE", + help=( + "with --test, override telemetry values to prove a rule fires " + "without waiting for real conditions" + ), + ) + run_parser.set_defaults(func=cmd_run) + + return parser + + +def main(): + configure_event_loop() + + parser = build_parser() + args = parser.parse_args() + + if args.command == "run" and args.test and not args.cycles: + args.cycles = 3 + + if args.command == "run" and getattr(args, "simulate", None) and not args.test: + fail("--simulate only works together with --test") + + try: + args.func(args) + except KeyboardInterrupt: + print() + print("stopped") + except ProfileError as err: + fail(str(err)) + + +if __name__ == "__main__": + main() diff --git a/solixauto/credentials.py b/solixauto/credentials.py new file mode 100644 index 0000000..c17631a --- /dev/null +++ b/solixauto/credentials.py @@ -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) diff --git a/solixauto/engine.py b/solixauto/engine.py new file mode 100644 index 0000000..2c92251 --- /dev/null +++ b/solixauto/engine.py @@ -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) diff --git a/solixauto/notify.py b/solixauto/notify.py new file mode 100644 index 0000000..e055f40 --- /dev/null +++ b/solixauto/notify.py @@ -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/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 diff --git a/solixauto/paths.py b/solixauto/paths.py new file mode 100644 index 0000000..63a4847 --- /dev/null +++ b/solixauto/paths.py @@ -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 diff --git a/solixauto/profiles.py b/solixauto/profiles.py new file mode 100644 index 0000000..df92e58 --- /dev/null +++ b/solixauto/profiles.py @@ -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" diff --git a/solixauto/rules.py b/solixauto/rules.py new file mode 100644 index 0000000..f21678b --- /dev/null +++ b/solixauto/rules.py @@ -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, "", "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 diff --git a/solixauto/service.py b/solixauto/service.py new file mode 100644 index 0000000..d292823 --- /dev/null +++ b/solixauto/service.py @@ -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}\n {value}\n" + + env_block = "" + if env_entries: + env_block = ( + " EnvironmentVariables\n" + " \n" + f"{env_entries}" + " \n" + ) + + return f""" + + + + Label + {label} + + ProgramArguments + + {sys.executable} + {script} + run + {profile_name} + --quiet + + + WorkingDirectory + {Path(script).parent} + +{env_block} RunAtLoad + + + KeepAlive + + + ThrottleInterval + 60 + + StandardOutPath + {log_dir / f"{profile_name}.out.log"} + + StandardErrorPath + {log_dir / f"{profile_name}.err.log"} + + +""" + + +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" diff --git a/solixauto/setup.py b/solixauto/setup.py new file mode 100644 index 0000000..38f8db4 --- /dev/null +++ b/solixauto/setup.py @@ -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 diff --git a/solixauto/shelly.py b/solixauto/shelly.py new file mode 100644 index 0000000..3a4ae40 --- /dev/null +++ b/solixauto/shelly.py @@ -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
." + ) + + 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 diff --git a/solixauto/templates.py b/solixauto/templates.py new file mode 100644 index 0000000..5426f4c --- /dev/null +++ b/solixauto/templates.py @@ -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 --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- + 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 ` 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 + +To remove them without leaving the terminal: + + solixauto conflicts --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 --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 --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 diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..f652401 --- /dev/null +++ b/start.bat @@ -0,0 +1,35 @@ +@echo off +setlocal + +cd /d "%~dp0" + +echo. +echo ============================================================== +echo Anker SOLIX to Shelly automation - setup +echo ============================================================== +echo. + +set PYTHON= + +for %%P in (py python) do ( + if not defined PYTHON ( + where %%P >nul 2>&1 && set PYTHON=%%P + ) +) + +if not defined PYTHON ( + echo No Python found on PATH. + echo Install Python 3.12 or newer from https://www.python.org/downloads/ + echo Tick "Add python.exe to PATH" during installation, then run this again. + echo. + pause + exit /b 1 +) + +echo Using: %PYTHON% +echo. + +%PYTHON% "%~dp0solixauto.py" setup + +echo. +pause diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..5c1c09e --- /dev/null +++ b/start.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$HERE" + +VENV_DIR="${SOLIXAUTO_VENV:-$HOME/solix-automation/venv}" +REPO_URL="https://github.com/thomluther/anker-solix-api.git" + +echo +echo "==============================================================" +echo " Anker SOLIX to Shelly automation - setup" +echo "==============================================================" +echo + +works() { + [ -x "$1" ] && "$1" -c "import anker_solix_api.api" >/dev/null 2>&1 +} + +usable_python() { + local candidates=( + "$VENV_DIR/bin/python" + "$HOME/anker-solix-mqtt/venv/bin/python" + "$HERE/venv/bin/python" + ) + for candidate in "${candidates[@]}"; do + if works "$candidate"; then + echo "$candidate" + return 0 + fi + done + for name in python3.14 python3.13 python3.12 python3; do + if command -v "$name" >/dev/null 2>&1 && works "$(command -v "$name")"; then + command -v "$name" + return 0 + fi + done + return 1 +} + +base_python() { + if [ -x "$VENV_DIR/bin/python" ]; then + echo "$VENV_DIR/bin/python" + return 0 + fi + for name in python3.14 python3.13 python3.12 python3; do + if command -v "$name" >/dev/null 2>&1; then + local version + version="$("$name" -c 'import sys; print(sys.version_info[0]*100+sys.version_info[1])' 2>/dev/null || echo 0)" + if [ "$version" -ge 312 ]; then + command -v "$name" + return 0 + fi + fi + done + return 1 +} + +PYTHON="$(usable_python || true)" + +if [ -n "${PYTHON:-}" ]; then + echo "Using Python: $PYTHON" + echo + exec "$PYTHON" "$HERE/solixauto.py" setup +fi + +echo "Nothing on this machine can talk to Anker yet." +echo + +BASE="$(base_python || true)" + +if [ -z "${BASE:-}" ]; then + echo "Python 3.12 or newer is required and was not found." + echo + if command -v brew >/dev/null 2>&1; then + echo "Install it with: brew install python@3.13" + elif command -v apt >/dev/null 2>&1; then + echo "Install it with: sudo apt install python3 python3-venv python3-pip git" + else + echo "Install Python 3.12+ from https://www.python.org/downloads/" + fi + echo + echo "Then run this script again." + exit 1 +fi + +if ! command -v git >/dev/null 2>&1; then + echo "Git is required to fetch the Anker library, and was not found." + echo + if [ "$(uname)" = "Darwin" ]; then + echo "Install the Xcode command line tools: xcode-select --install" + else + echo "Install git with your package manager, for example: sudo apt install git" + fi + echo + echo "Then run this script again." + exit 1 +fi + +echo "This will create a self-contained Python environment and install" +echo "everything needed. Nothing outside these two locations is touched:" +echo +echo " $VENV_DIR" +echo " $HOME/solix-automation" +echo +printf "Set it up now? [Y/n]: " +read -r reply +case "${reply:-y}" in + [nN]*) echo "Nothing was changed."; exit 0 ;; +esac + +echo +echo "Creating the environment with $BASE ..." +mkdir -p "$(dirname "$VENV_DIR")" +"$BASE" -m venv "$VENV_DIR" + +VENV_PYTHON="$VENV_DIR/bin/python" +[ -x "$VENV_PYTHON" ] || VENV_PYTHON="$VENV_DIR/Scripts/python.exe" + +if [ ! -x "$VENV_PYTHON" ]; then + echo "Could not create the environment at $VENV_DIR" + exit 1 +fi + +echo "Installing packages. This takes a minute." +echo +"$VENV_PYTHON" -m pip install --upgrade pip --quiet +"$VENV_PYTHON" -m pip install -r "$HERE/requirements.txt" --quiet +"$VENV_PYTHON" -m pip install "git+$REPO_URL" --quiet + +# The upstream package does not declare its runtime dependencies, so a plain +# install of it imports the package but fails on first real use. +"$VENV_PYTHON" -m pip install aiofiles cryptography paho-mqtt --quiet + +if ! works "$VENV_PYTHON"; then + echo + echo "Some dependencies are still missing. The setup wizard will resolve" + echo "the rest as it goes." +fi + +echo +echo "Environment ready: $VENV_PYTHON" +echo +echo "Tip: from now on you can run commands as" +echo " $VENV_PYTHON solixauto.py " +echo +exec "$VENV_PYTHON" "$HERE/solixauto.py" setup