Initial commit
This commit is contained in:
+24
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
aiohttp
|
||||||
|
pyyaml
|
||||||
|
zeroconf
|
||||||
|
python-dotenv
|
||||||
|
ifaddr
|
||||||
|
qrcode
|
||||||
Executable
+10
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "1.0.0"
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user