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