Initial commit

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