from . import paths from .profiles import list_profiles, write_text POWER_PROFILE_TEMPLATE = """# Power profile: __NAME__ # # Links ONE Anker SOLIX device (read-only data source) to ONE Shelly # switch channel (the actuator). Rules decide when the Shelly turns on # or off. The Anker device is never commanded. # # Validate before running: # __INVOCATION__ run __NAME__ --test # # --------------------------------------------------------------------- # QUICK REFERENCE # --------------------------------------------------------------------- # when: an expression over fields from the Anker device profile. # Use field names directly. Allowed: < <= > >= == != # and / or / not, + - * /, and parentheses. # Run `__INVOCATION__ fields __SOURCE__` to list every name. # # for: how long the condition must hold continuously before acting. # Prevents a passing cloud from toggling the relay. 30s 5m 1h. # # then: target.on, target.off, or none. # # priority: when several rules are ready at once, the highest number # wins. Default 0. # # notify: on or off per rule. Omit to inherit the profile default. # Only matters when notifications.enabled is true below. # # --------------------------------------------------------------------- name: __NAME__ description: > Describe what this profile is for. enabled: true poll_interval: 10s source: profile: __SOURCE__ stale_after: 120s on_stale: safe_state target: profile: __TARGET__ channel: __CHANNEL__ safe_state: on # SAFETY FLOOR - evaluated before any rule below, and it bypasses the rate # limits. Once tripped it LATCHES: nothing can turn the target off again until # the battery climbs back to release_at. Set this whenever the target controls # charging for the source device, so the battery can never be stranded at 0%. safety: battery_floor: at_or_below: 25 release_at: 45 then: target.on for: 30s notify: true notify_release: true # Notifications fire when a rule actually switches the target. # Channels and credentials live in ../notifications.yaml, not here. # solixauto notify-test notifications: enabled: false channels: [] title: "{profile}" template: >- {source_name} battery {battery_soc}%, solar {pv_total}W. {target_name} turned {action}. throttle: 5m on: - action rules: __RULES__ limits: min_seconds_between_actions: 60 max_actions_per_hour: 20 """ RULES_SOLAR = """ - name: grid assist when solar drops when: pv_total < 150 for: 90s then: target.on - name: release grid when solar recovers when: pv_total > 300 for: 2m then: target.off - name: emergency charge on low battery when: battery_soc <= 15 for: 30s then: target.on priority: 100 notify: template: >- {source_name} has {battery_soc}% battery remaining. {target_name} turned on AC power. priority: high """ RULES_BATTERY = """ - name: charge when battery is low when: battery_soc <= 15 for: 30s then: target.on notify: template: >- {source_name} has {battery_soc}% battery remaining. {target_name} turned on AC power. - name: stop charging when battery is healthy when: battery_soc >= 60 for: 2m then: target.off """ RULES_MINIMAL = """ - name: turn on when: battery_soc <= 20 for: 60s then: target.on - name: turn off when: battery_soc >= 50 for: 60s then: target.off """ RULE_SETS = { "solar": RULES_SOLAR, "battery": RULES_BATTERY, "minimal": RULES_MINIMAL, } README_HEADER = """# Power profiles > Commands below are written as `solixauto`. If that is not on your PATH, run > them the same way you ran the tool, for example: > > __INVOCATION__ run --test """ README = """# Power profiles A power profile is a plain YAML file linking one Anker SOLIX device to one Shelly switch channel. You can edit it in any text editor. The Anker device is **read-only**. The engine reads its telemetry and never sends it a command. The only thing that gets switched is the Shelly. ## Layout device-profiles/anker/ generated, one file per Anker device device-profiles/shelly/ generated, one file per Shelly device power-profiles/ yours, hand-edited state/runtime.json last known target state per profile logs/automation.log action history ## Workflow solixauto discover-anker solixauto discover-shelly solixauto new-profile solar-failover --template solar solixauto fields A1782- solixauto run solar-failover --test solixauto run solar-failover ## Anatomy of a rule - name: grid assist when solar drops when: pv_total < 150 for: 90s then: target.on priority: 0 `when` is an expression over any field listed in the Anker device profile, under `readable` or `derived`. `solixauto fields ` prints them with current sample values. `for` is the dwell time. The condition must stay true for this long before anything happens. Without it, a cloud passing over your array would cycle the relay repeatedly. `then` is `target.on`, `target.off`, or `none`. `priority` breaks ties. If two rules are ready at the same moment and disagree, the higher number wins. A low-battery override should outrank normal solar logic. ## Use a deadband This is the single most important thing to get right: # WRONG - will chatter around 200W - when: pv_total < 200 then: target.on - when: pv_total > 200 then: target.off # RIGHT - 150W of deadband between the two - when: pv_total < 150 then: target.on - when: pv_total > 300 then: target.off `--test` warns when it detects the same threshold used for both directions. ## Useful automations **Solar failover.** Solar covers the load most of the day; pull from the wall only when production drops. - name: grid assist when solar drops when: pv_total < 150 for: 90s then: target.on - name: release grid when solar recovers when: pv_total > 300 for: 2m then: target.off **Low-battery charge.** No solar involved. - name: charge when battery is low when: battery_soc <= 15 for: 30s then: target.on - name: stop charging when battery is healthy when: battery_soc >= 60 for: 2m then: target.off **Overnight top-up.** Combine conditions. - name: cheap overnight charging when: battery_soc < 80 and pv_total < 20 for: 5m then: target.on **Let solar do the work.** Stop grid charging once the sun is carrying the load and has spare capacity for the battery. `pv_surplus` is solar minus everything drawing from the unit, so positive means the surplus is going into the battery. - name: solar covers the load, stop grid charging when: pv_surplus > 100 for: 5m then: target.off - name: solar cannot keep up, fall back to grid when: pv_surplus < -100 and battery_soc <= 60 for: 10m then: target.on The 100W band on either side of zero is the deadband; without it, a load cycling on and off would flip the relay every few minutes. The SOC condition on the second rule stops it reaching for the grid on a cloudy afternoon when the battery is still comfortable. **Load shedding.** Cut a non-essential circuit when the battery is draining. - name: shed load when: battery_soc < 30 and ac_input_power == 0 for: 2m then: target.off **Thermal guard.** Higher priority so it outranks everything else. - name: stop charging when hot when: temperature >= 45 for: 60s then: target.off priority: 200 ## Safety settings `stale_after` and `on_stale` control what happens when telemetry stops arriving. Rules are never evaluated against stale data. - `hold` keeps the relay wherever it is. Default and safest. - `safe_state` drives the relay to the `safe_state:` value. - `stop` exits the engine. `limits` is a hard backstop independent of dwell times: limits: min_seconds_between_actions: 60 max_actions_per_hour: 20 If a rule somehow oscillates, this caps the damage. ## The safety floor This is not a rule. It is checked before every rule, it ignores the rate limits, and once tripped it latches. safety: battery_floor: at_or_below: 25 release_at: 45 then: target.on for: 30s notify: true Set this whenever the Shelly controls charging for the Anker device itself. Without it you can deadlock: a rule turns charging off, the battery runs flat, the device drops off wifi, telemetry goes stale, and nothing ever turns charging back on. `release_at` must be meaningfully higher than `at_or_below`. While latched, no rule can turn the target off, so the battery gets a real recharge instead of being released at 26% into the same conditions that drained it. Pair it with a stale policy that fails toward charging: source: stale_after: 300s on_stale: safe_state safe_state: on `--test` warns when no floor is set. ### Floor notifications The floor notifies on both trip and release, and it does this **even when `notifications.enabled` is false**. Turning off routine solar chatter should not silence a battery emergency. All it needs is one enabled channel in `notifications.yaml`. Push channels get urgent priority, and the throttle is bypassed. safety: battery_floor: at_or_below: 25 notify: true notify_release: true notify_template: >- SAFETY: {source_name} battery at {value}. {target_name} turned {action} to protect it. Holding until {release}. release_template: >- {source_name} battery recovered to {value}. Normal rules resumed for {target_name}. Extra fields available in these two templates: `{value}`, `{field}`, `{threshold}`, `{release}`. If the floor trips and no channel is configured, the log says so explicitly rather than failing silently. ## Conflicts with the Shelly's own automation A Shelly can run schedules, auto-on/auto-off timers, and webhooks entirely on the device. Those keep running whether or not this engine is running, and they will fight your rules. A schedule that turns the plug off at 08:00 will undo a rule that just turned it on, and nothing in the log will explain why. Check before you rely on anything: solixauto conflicts To remove them without leaving the terminal: solixauto conflicts --fix That asks before every change and defaults to No. Deletions happen on the device and cannot be undone from here; you would have to recreate them in the Shelly app. Discovery records what it finds, `--test` lists it as notes, and the engine prints a loud warning at startup if anything is still there. Remove them in the Shelly app, or accept that the device wins. One limitation: schedules created as Shelly Cloud **scenes** live in the cloud rather than on the device, and cannot be seen over local HTTP. If behaviour still looks wrong after `conflicts` comes back clean, check the app's scenes. ## Notifications Turn them on in the profile: notifications: enabled: true channels: [ntfy] title: "{profile}" template: >- {source_name} battery {battery_soc}%, solar {pv_total}W. {target_name} turned {action}. throttle: 5m on: - action Then per rule, `notify: on` or `notify: off` to include or exclude it. Omit it to inherit. A rule can also carry its own wording: - name: emergency charge on low battery when: battery_soc <= 15 for: 30s then: target.on priority: 100 notify: template: >- {source_name} has {battery_soc}% battery remaining. {target_name} turned on AC power. priority: high ### Template fields Any field from the device profile works, plus: {profile} power profile name {rule} rule that fired {condition} the rule's when expression {action} ON or OFF {action_word} on or off {source_name} Anker device name, falling back to model {source_model} e.g. SOLIX F3000 {source_serial} {target_name} Shelly device name, falling back to model {target_model} {target_host} {time} `--test` checks every field name in your templates against the device profile, so a typo is caught before it ships a message reading `battery ?%`. ### Channels Credentials live in `../notifications.yaml`, which is created with owner-only permissions. Power profiles stay free of secrets. - **ntfy** - recommended. Free, no account. Install the app, pick an unguessable topic name, subscribe. Anyone who knows the topic can read your alerts, so make it long. - **pushover** - $5 once per platform. Priority 2 alerts repeat until you acknowledge them. - **email** - SMTP. Gmail requires an App Password. - **telegram** - free bot. - **webhook** - Slack, Discord, or generic JSON. - **desktop** - local notification on the machine running the engine. Uses osascript on macOS, notify-send on Linux, PowerShell on Windows. Run `solixauto doctor` to see which backend was detected. The Anker app and the Shelly app cannot receive custom push messages from an external program, so neither is an option here. Test a channel: solixauto notify-test solixauto notify-test --channel ntfy ### Throttling `throttle` is per rule. An identical repeated message inside the window is dropped. This is separate from the switching rate limits, so a stuck condition cannot flood your phone even if the relay is behaving. ### Other events on: - action - stale `stale` fires once when telemetry stops arriving and is worth enabling if you depend on the automation. ## Testing solixauto run --test Validates syntax, checks that every field you reference actually exists on the device, warns about missing deadbands and short dwell times, connects to both devices, and prints what each rule would do right now. Nothing is switched. solixauto run --test --offline Same checks without connecting. Rules are evaluated against the sample values captured during discovery. """ def scaffold_readme(): destination = paths.POWER_PROFILE_DIR / "README.md" header = README_HEADER.replace("__INVOCATION__", paths.invocation()) body = README.split("\n", 1)[1] if README.startswith("# Power profiles") else README write_text(destination, header.rstrip() + "\n" + body) return destination def default_source(): profiles = list_profiles(paths.ANKER_PROFILE_DIR) return profiles[0].name if profiles else "REPLACE-WITH-ANKER-PROFILE.yaml" def default_target(): profiles = list_profiles(paths.SHELLY_PROFILE_DIR) return profiles[0].name if profiles else "REPLACE-WITH-SHELLY-PROFILE.yaml" def render(name, source=None, target=None, channel=0, template="solar"): rules = RULE_SETS.get(template, RULES_SOLAR) output = POWER_PROFILE_TEMPLATE for token, value in ( ("__NAME__", name), ("__SOURCE__", source or default_source()), ("__TARGET__", target or default_target()), ("__CHANNEL__", str(channel)), ("__RULES__", rules), ("__INVOCATION__", paths.invocation()), ): output = output.replace(token, value) return output def create(name, source=None, target=None, channel=0, template="solar", force=False): paths.ensure_dirs() destination = paths.POWER_PROFILE_DIR / f"{name}.yaml" if destination.exists() and not force: raise FileExistsError(destination) write_text(destination, render(name, source, target, channel, template)) scaffold_readme() return destination