diff --git a/packages/energy.yaml b/packages/energy.yaml new file mode 100644 index 0000000..a0d6671 --- /dev/null +++ b/packages/energy.yaml @@ -0,0 +1,167 @@ +# ────────────────────────────────────────────────────────────────────────────── +# Energy — shared PV-surplus primitives + Shelly pool pump hardware +# +# This package owns two concerns that are deliberately kept together: +# 1. Shelly Plus Plug S monitoring for the pool pump (MQTT sensors + switch) +# 2. Canonical PV-surplus / free-energy signals reusable by any load package +# (pool pump automations, future HRDS dehumidifier, etc.) +# +# Shelly MQTT topics: +# Status: shelly/shellyplusplugs-pool-pump/status/switch:0 (JSON, 15 min or on change) +# Command: shelly/shellyplusplugs-pool-pump/command/switch:0 (payload: "on" / "off") +# +# PV surplus definition: +# sensor.energy_pv_surplus = sensor.victron_grid_power_export +# = the non-negative AC export power to the grid (W). +# Positive only when the system is a net exporter — battery charging and house +# loads are already subtracted by the Victron system before this hits the meter. +# ────────────────────────────────────────────────────────────────────────────── + +mqtt: + sensor: + + - name: "Pool Pump Power" + unique_id: "shelly_pool_pump_power" + state_topic: "shelly/shellyplusplugs-pool-pump/status/switch:0" + unit_of_measurement: "W" + device_class: power + state_class: measurement + expire_after: 120 + icon: mdi:pump + device: &shelly_pool_pump + identifiers: + - shellyplusplugs_pool_pump + name: "Pool Pump" + manufacturer: "Shelly" + model: "Plus Plug S" + suggested_area: "Pool" + value_template: "{{ value_json.apower | round(1) }}" + + # Cumulative lifetime energy from Shelly firmware (Wh → kWh). + # Never resets unless the device is factory-reset; total_increasing tells + # HA to handle any dip (reboot reset) gracefully. + - name: "Pool Pump Energy" + unique_id: "shelly_pool_pump_energy" + state_topic: "shelly/shellyplusplugs-pool-pump/status/switch:0" + unit_of_measurement: "kWh" + device_class: energy + state_class: total_increasing + expire_after: 120 + icon: mdi:lightning-bolt + device: *shelly_pool_pump + value_template: "{% if value_json.aenergy is not none %}{{ (value_json.aenergy.total / 1000) | round(3) }}{% endif %}" + + - name: "Pool Pump Voltage" + unique_id: "shelly_pool_pump_voltage" + state_topic: "shelly/shellyplusplugs-pool-pump/status/switch:0" + unit_of_measurement: "V" + device_class: voltage + state_class: measurement + expire_after: 120 + icon: mdi:sine-wave + device: *shelly_pool_pump + value_template: "{{ value_json.voltage | round(1) }}" + + - name: "Pool Pump Current" + unique_id: "shelly_pool_pump_current" + state_topic: "shelly/shellyplusplugs-pool-pump/status/switch:0" + unit_of_measurement: "A" + device_class: current + state_class: measurement + expire_after: 120 + icon: mdi:current-ac + device: *shelly_pool_pump + value_template: "{{ value_json.current | round(3) }}" + + - name: "Pool Pump Temperature" + unique_id: "shelly_pool_pump_temperature" + state_topic: "shelly/shellyplusplugs-pool-pump/status/switch:0" + unit_of_measurement: "°C" + device_class: temperature + state_class: measurement + expire_after: 120 + device: *shelly_pool_pump + value_template: "{{ value_json.temperature.tC | round(1) }}" + + binary_sensor: + + - name: "Pool Pump Switch" + unique_id: "shelly_pool_pump_switch" + state_topic: "shelly/shellyplusplugs-pool-pump/status/switch:0" + device_class: power + expire_after: 120 + device: *shelly_pool_pump + value_template: "{{ value_json.output | string | lower }}" + payload_on: "true" + payload_off: "false" + + switch: + + # HA-controlled switch — sends on/off commands to the Shelly plug. + # The Shelly's built-in schedule must be disabled in its web UI before + # this switch is used, to prevent the device fighting HA commands. + - name: "Pool Pump" + unique_id: "shelly_pool_pump_control" + command_topic: "shelly/shellyplusplugs-pool-pump/command/switch:0" + state_topic: "shelly/shellyplusplugs-pool-pump/status/switch:0" + value_template: "{{ value_json.output }}" + payload_on: "on" + payload_off: "off" + device_class: switch + device: *shelly_pool_pump + + +# ── Shared energy helpers ────────────────────────────────────────────────────── +input_number: + + energy_battery_soc_min: + name: "Energy Battery SoC Minimum" + min: 0 + max: 100 + step: 5 + unit_of_measurement: "%" + mode: box + icon: mdi:battery-low + + energy_min_surplus: + name: "Energy Minimum PV Surplus" + min: 0 + max: 2000 + step: 50 + unit_of_measurement: "W" + mode: box + icon: mdi:solar-power + + +# ── Derived energy template sensors ─────────────────────────────────────────── +template: + - sensor: + + # Canonical PV surplus signal for all packages. + # Definition: Victron grid export power (W) — true surplus after house loads + # and battery charging are already served. Zero when the system is a net importer. + - name: "Energy PV Surplus" + unique_id: energy_pv_surplus + state: "{{ states('sensor.victron_grid_power_export') | float(0) }}" + unit_of_measurement: "W" + device_class: power + state_class: measurement + icon: mdi:solar-power + availability: > + {{ states('sensor.victron_grid_power_export') not in ['unavailable', 'unknown'] }} + + - binary_sensor: + + # Generic "free energy is available" gate — reusable by any opportunistic load. + # ON when: PV surplus exceeds the noise floor AND battery SoC is not depleted. + # The AND (not OR) is intentional: battery-only surplus is not "free" PV energy. + - name: "Energy Free Available" + unique_id: energy_free_available + state: > + {{ states('sensor.energy_pv_surplus') | float(0) + > states('input_number.energy_min_surplus') | float + and states('sensor.victron_battery_soc') | float(0) + >= states('input_number.energy_battery_soc_min') | float }} + availability: > + {{ states('sensor.energy_pv_surplus') not in ['unavailable', 'unknown'] + and states('sensor.victron_battery_soc') not in ['unavailable', 'unknown'] }} diff --git a/packages/pool_pump.yaml b/packages/pool_pump.yaml new file mode 100644 index 0000000..f909ace --- /dev/null +++ b/packages/pool_pump.yaml @@ -0,0 +1,285 @@ +# ────────────────────────────────────────────────────────────────────────────── +# Pool Pump — schedule, water quality monitoring, and PV-aware control +# +# Hardware controlled: +# switch.pool_pump (Shelly Plus Plug S, defined in packages/energy.yaml) +# +# Water quality monitoring: +# VistaPool integration (cloud push, UI-configured), device name "Mike": +# sensor.mike_redox_potential — ORP / Rx redox potential (mV) +# sensor.mike_ph — pH value +# sensor.mike_temperature — pool water temperature (°C) +# +# Normal schedule (replaces Shelly's built-in schedule — disable that in the +# Shelly web UI before this package goes live): +# Daily 11:00–17:30 → schedule.pool_pump_normal +# +# PV-driven early start (morning window 08:00–11:00): +# If PV export ≥ pool_pv_surplus_threshold → start pump early. +# If PV drops before 11:00 → stop again (schedule takes over at 11:00). +# +# Extended run (post-schedule, after 17:30): +# If outdoor temp > pool_temp_threshold AND ORP alarm AND +# (PV surplus ≥ threshold OR battery SOC ≥ pool_battery_soc_min) +# → keep pump running until any condition clears. +# ────────────────────────────────────────────────────────────────────────────── + + +# ── Helpers ─────────────────────────────────────────────────────────────────── +input_number: + + pool_pv_surplus_threshold: + name: "Pool Pump PV Surplus Threshold" + min: 0 + max: 5000 + step: 50 + unit_of_measurement: "W" + mode: box + icon: mdi:solar-power + + pool_battery_soc_min: + name: "Pool Pump Battery SoC Minimum" + min: 0 + max: 100 + step: 5 + unit_of_measurement: "%" + mode: box + icon: mdi:battery-low + + pool_temp_threshold: + name: "Pool Pump Temperature Threshold" + min: 15 + max: 45 + step: 0.5 + unit_of_measurement: "°C" + mode: box + icon: mdi:thermometer + + pool_orp_alarm_threshold: + name: "Pool ORP Alarm Threshold" + min: 300 + max: 900 + step: 10 + unit_of_measurement: "mV" + mode: box + icon: mdi:water-check + + +input_boolean: + + pool_pump_extended_run: + name: "Pool Pump Extended Run Active" + icon: mdi:pump + + +# ── Template binary sensors ──────────────────────────────────────────────────── +template: + - binary_sensor: + + # True when PV export meets the pool's operational surplus threshold. + # Uses sensor.energy_pv_surplus (from energy.yaml) as the canonical source. + # Distinct from energy_free_available: early start is PV-only, no battery gate. + - name: "Pool PV Surplus Available" + unique_id: pool_pv_surplus_available + state: > + {{ states('sensor.energy_pv_surplus') | float(0) + >= states('input_number.pool_pv_surplus_threshold') | float }} + availability: > + {{ states('sensor.energy_pv_surplus') not in ['unavailable', 'unknown'] }} + + # Extended-run energy gate: PV surplus OR battery SoC above minimum. + # OR because either energy source is sufficient to sustain the extended run. + - name: "Pool Extended Run Energy OK" + unique_id: pool_extended_run_energy_ok + state: > + {{ is_state('binary_sensor.pool_pv_surplus_available', 'on') + or states('sensor.victron_battery_soc') | float(0) + >= states('input_number.pool_battery_soc_min') | float }} + availability: > + {{ states('binary_sensor.pool_pv_surplus_available') not in ['unavailable', 'unknown'] + and states('sensor.victron_battery_soc') not in ['unavailable', 'unknown'] }} + + # True when pool ORP/redox is below the alarm threshold (insufficient disinfection). + # Unavailable when the VistaPool cloud sensor is offline — prevents false alarms. + - name: "Pool ORP Alarm" + unique_id: pool_orp_alarm + state: > + {{ states('sensor.mike_redox_potential') | float(999) + < states('input_number.pool_orp_alarm_threshold') | float }} + availability: > + {{ states('sensor.mike_redox_potential') not in ['unavailable', 'unknown'] }} + + # All three extended-run conditions in one sensor. + # Used as the trigger source for both enabling and stopping the extended run. + - name: "Pool Extended Run Conditions Met" + unique_id: pool_extended_run_conditions_met + state: > + {{ states('sensor.wheatherstation_outdoor_temperature') | float(0) + > states('input_number.pool_temp_threshold') | float + and is_state('binary_sensor.pool_orp_alarm', 'on') + and is_state('binary_sensor.pool_extended_run_energy_ok', 'on') }} + availability: > + {{ states('sensor.wheatherstation_outdoor_temperature') not in ['unavailable', 'unknown'] + and states('binary_sensor.pool_orp_alarm') not in ['unavailable', 'unknown'] + and states('binary_sensor.pool_extended_run_energy_ok') not in ['unavailable', 'unknown'] }} + + +# ── Schedule ────────────────────────────────────────────────────────────────── +# Replicates the Shelly's built-in 11:00–17:30 schedule. +# The Shelly internal schedule must be disabled manually in its web UI. +schedule: + pool_pump_normal: + name: "Pool Pump Schedule" + monday: + - from: "11:00:00" + to: "17:30:00" + tuesday: + - from: "11:00:00" + to: "17:30:00" + wednesday: + - from: "11:00:00" + to: "17:30:00" + thursday: + - from: "11:00:00" + to: "17:30:00" + friday: + - from: "11:00:00" + to: "17:30:00" + saturday: + - from: "11:00:00" + to: "17:30:00" + sunday: + - from: "11:00:00" + to: "17:30:00" + + +# ── Automations ─────────────────────────────────────────────────────────────── +automation: + + # 1. Schedule start — turn pump on when the normal window opens. + # Idempotent: if PV early-start already turned the pump on, this is a no-op. + - id: pool_pump_schedule_on + alias: "Pool: Schedule On" + description: "Turn pool pump on when normal schedule window opens (11:00)" + mode: single + trigger: + - platform: state + entity_id: schedule.pool_pump_normal + to: "on" + action: + - action: switch.turn_on + target: + entity_id: switch.pool_pump + + # 2. Schedule end — at 17:30, either start extended run or turn pump off. + - id: pool_pump_schedule_off + alias: "Pool: Schedule Off" + description: "At schedule end check extended-run conditions; keep pump running or turn off" + mode: single + trigger: + - platform: state + entity_id: schedule.pool_pump_normal + to: "off" + # Only fire on the evening transition (17:30), not at midnight rollover. + # The schedule is off from 17:30 to 11:00 the next day; we only want to act + # when the pump has been running and the window just closed. + for: "00:00:00" + condition: + # Only act if the pump is actually on — avoids spurious runs at HA startup + - condition: state + entity_id: switch.pool_pump + state: "on" + action: + - choose: + - conditions: + - condition: state + entity_id: binary_sensor.pool_extended_run_conditions_met + state: "on" + sequence: + # Keep pump running; flag tracks that we are past the schedule window. + - action: input_boolean.turn_on + target: + entity_id: input_boolean.pool_pump_extended_run + default: + - action: switch.turn_off + target: + entity_id: switch.pool_pump + + # 3. PV early start — turn pump on during the morning window when surplus is available. + # Morning window: 08:00–11:00 (before schedule.pool_pump_normal becomes active). + - id: pool_pump_early_start + alias: "Pool: PV Early Start" + description: "Start pump early when PV export ≥ threshold in the morning (08:00–11:00)" + mode: single + trigger: + - platform: state + entity_id: binary_sensor.pool_pv_surplus_available + to: "on" + condition: + # Only in the morning window before the normal schedule starts + - condition: state + entity_id: schedule.pool_pump_normal + state: "off" + - condition: time + after: "08:00:00" + before: "11:00:00" + action: + - action: switch.turn_on + target: + entity_id: switch.pool_pump + + # 4. PV early stop — cancel the early start if surplus drops before schedule opens. + # Once the schedule is active (≥ 11:00) the schedule-off automation owns shutdown. + - id: pool_pump_early_stop + alias: "Pool: PV Early Stop" + description: "Stop pump if PV surplus drops before normal schedule starts" + mode: single + trigger: + - platform: state + entity_id: binary_sensor.pool_pv_surplus_available + to: "off" + condition: + # Schedule not yet active (before 11:00) — schedule window governs after that + - condition: state + entity_id: schedule.pool_pump_normal + state: "off" + - condition: time + after: "08:00:00" + before: "11:00:00" + - condition: state + entity_id: switch.pool_pump + state: "on" + action: + - action: switch.turn_off + target: + entity_id: switch.pool_pump + + # 5. Extended run stop — turn pump off when any extended-run condition clears. + # Two triggers: conditions-cleared and flag-just-set (safety re-check at enable time). + - id: pool_pump_extended_run_stop + alias: "Pool: Extended Run Stop" + description: "Stop pump when extended-run conditions (temp/ORP/energy) are no longer met" + mode: single + trigger: + - platform: state + entity_id: binary_sensor.pool_extended_run_conditions_met + to: "off" + # Re-evaluate immediately after the flag is set, in case conditions cleared + # in the same second the schedule ended and set the flag. + - platform: state + entity_id: input_boolean.pool_pump_extended_run + to: "on" + condition: + - condition: state + entity_id: input_boolean.pool_pump_extended_run + state: "on" + - condition: state + entity_id: binary_sensor.pool_extended_run_conditions_met + state: "off" + action: + - action: switch.turn_off + target: + entity_id: switch.pool_pump + - action: input_boolean.turn_off + target: + entity_id: input_boolean.pool_pump_extended_run diff --git a/packages/shelly_pool_pump.yaml b/packages/shelly_pool_pump.yaml deleted file mode 100644 index 797923d..0000000 --- a/packages/shelly_pool_pump.yaml +++ /dev/null @@ -1,90 +0,0 @@ -# ────────────────────────────────────────────────────────────────────────────── -# Shelly Plus Plug S — Pool Pump -# -# Hardware: Shelly Plus Plug S (shellyplusplugs-pool-pump) -# MQTT topic: shelly/shellyplusplugs-pool-pump/status/switch:0 -# -# Energy Dashboard sensors: -# sensor.pool_pump_power → instantaneous draw in W (individual device power) -# sensor.pool_pump_energy → cumulative kWh (total_increasing, for dashboard) -# -# aenergy.total from Shelly firmware is in Wh — divided by 1000 to yield kWh. -# ────────────────────────────────────────────────────────────────────────────── - -mqtt: - sensor: - - - name: "Pool Pump Power" - unique_id: "shelly_pool_pump_power" - state_topic: "shelly/shellyplusplugs-pool-pump/status/switch:0" - unit_of_measurement: "W" - device_class: power - state_class: measurement - expire_after: 120 - icon: mdi:pump - device: &shelly_pool_pump - identifiers: - - shellyplusplugs_pool_pump - name: "Pool Pump" - manufacturer: "Shelly" - model: "Plus Plug S" - suggested_area: "Pool" - value_template: "{{ value_json.apower | round(1) }}" - - # Cumulative lifetime energy from Shelly firmware (Wh → kWh). - # Never resets unless the device is factory-reset; total_increasing tells - # HA to handle any dip (reboot reset) gracefully. - - name: "Pool Pump Energy" - unique_id: "shelly_pool_pump_energy" - state_topic: "shelly/shellyplusplugs-pool-pump/status/switch:0" - unit_of_measurement: "kWh" - device_class: energy - state_class: total_increasing - expire_after: 120 - icon: mdi:lightning-bolt - device: *shelly_pool_pump - value_template: "{% if value_json.aenergy is not none %}{{ (value_json.aenergy.total / 1000) | round(3) }}{% endif %}" - - - name: "Pool Pump Voltage" - unique_id: "shelly_pool_pump_voltage" - state_topic: "shelly/shellyplusplugs-pool-pump/status/switch:0" - unit_of_measurement: "V" - device_class: voltage - state_class: measurement - expire_after: 120 - icon: mdi:sine-wave - device: *shelly_pool_pump - value_template: "{{ value_json.voltage | round(1) }}" - - - name: "Pool Pump Current" - unique_id: "shelly_pool_pump_current" - state_topic: "shelly/shellyplusplugs-pool-pump/status/switch:0" - unit_of_measurement: "A" - device_class: current - state_class: measurement - expire_after: 120 - icon: mdi:current-ac - device: *shelly_pool_pump - value_template: "{{ value_json.current | round(3) }}" - - - name: "Pool Pump Temperature" - unique_id: "shelly_pool_pump_temperature" - state_topic: "shelly/shellyplusplugs-pool-pump/status/switch:0" - unit_of_measurement: "°C" - device_class: temperature - state_class: measurement - expire_after: 120 - device: *shelly_pool_pump - value_template: "{{ value_json.temperature.tC | round(1) }}" - - binary_sensor: - - - name: "Pool Pump Switch" - unique_id: "shelly_pool_pump_switch" - state_topic: "shelly/shellyplusplugs-pool-pump/status/switch:0" - device_class: power - expire_after: 120 - device: *shelly_pool_pump - value_template: "{{ value_json.output | string | lower }}" - payload_on: "true" - payload_off: "false" diff --git a/plans/pool-pump-pv-automation.md b/plans/pool-pump-pv-automation.md new file mode 100644 index 0000000..0691b92 --- /dev/null +++ b/plans/pool-pump-pv-automation.md @@ -0,0 +1,147 @@ +# Pool Pump Package — Implementation Plan + +**Status: IMPLEMENTED** on branch `claude/pool-pump-pv-automation-5l1wes` + +## Context + +The pool pump was monitored via `packages/shelly_pool_pump.yaml` (Shelly Plus Plug S, MQTT), +but had no HA control capability: the on/off schedule lived inside the Shelly device itself. + +The HRDS plan (`plans/hrds-dehumidifier.md`, Decision 1) resolved the PV-surplus architecture: +a dedicated `packages/energy.yaml` owns the shared free-energy primitives and absorbs the Shelly +pool pump monitoring sensors. This plan implements `energy.yaml` ahead of the HRDS package so both +can reference the same canonical entities. + +--- + +## Files changed + +| Action | Path | +|--------|------| +| CREATE | `packages/energy.yaml` | +| CREATE | `packages/pool_pump.yaml` | +| DELETE | `packages/shelly_pool_pump.yaml` | +| CREATE | `tests/test_pool_pump.py` | +| MODIFY | `tests/conftest.py` | + +No changes to `configuration.yaml`, `automations.yaml`, or any other package. + +--- + +## VistaPool entity IDs (confirmed on live system) + +Device name in HA: **"Mike"** (Vistapool by Sugar Valley, Firmware 1281, area: Terrasse) + +| Entity | Description | +|--------|-------------| +| `sensor.mike_redox_potential` | ORP / Rx redox potential (mV) | +| `sensor.mike_ph` | pH value | +| `sensor.mike_temperature` | Pool water temperature (°C) | + +The ORP alarm is implemented as a configurable template threshold +(`input_number.pool_orp_alarm_threshold`, default 650 mV) — the VistaPool integration +does not expose a built-in alarm binary sensor. + +**Manual step required:** disable the Shelly's internal schedule in the Shelly web UI before +the HA-controlled schedule goes live, to prevent the device fighting HA commands. + +--- + +## `packages/energy.yaml` + +Shared energy layer — owned here, referenced by all load packages. + +- **Shelly MQTT monitoring** (migrated verbatim from `shelly_pool_pump.yaml`): + `sensor.pool_pump_power/energy/voltage/current/temperature`, `binary_sensor.pool_pump_switch` + All `unique_id`s preserved → entity IDs and history unchanged. +- **MQTT switch** `switch.pool_pump` — HA commands the Shelly plug. +- **`input_number.energy_battery_soc_min`** (default 80 %) — shared SOC floor. +- **`input_number.energy_min_surplus`** (default 50 W) — noise floor for free-energy gate. +- **`sensor.energy_pv_surplus`** — mirrors `sensor.victron_grid_power_export` (non-negative W). +- **`binary_sensor.energy_free_available`** — surplus > noise floor AND SOC ≥ minimum. + +--- + +## `packages/pool_pump.yaml` + +All pool-specific logic. + +### Helpers + +| Helper | Default | Purpose | +|--------|---------|---------| +| `input_number.pool_pv_surplus_threshold` | 500 W | Minimum PV export for early start / energy gate | +| `input_number.pool_battery_soc_min` | 80 % | SOC floor for extended-run energy gate | +| `input_number.pool_temp_threshold` | 30 °C | Outdoor temp above which extended run is considered | +| `input_number.pool_orp_alarm_threshold` | 650 mV | ORP below this → alarm on | +| `input_boolean.pool_pump_extended_run` | — | Tracks that pump is running past schedule end | + +### Template binary sensors + +- **`binary_sensor.pool_pv_surplus_available`** — `energy_pv_surplus >= pool_pv_surplus_threshold` +- **`binary_sensor.pool_extended_run_energy_ok`** — surplus available OR SOC ≥ minimum +- **`binary_sensor.pool_orp_alarm`** — `mike_redox_potential < pool_orp_alarm_threshold` +- **`binary_sensor.pool_extended_run_conditions_met`** — temp > threshold AND orp_alarm AND energy_ok + +All sensors have `availability:` guards. + +### Schedule + +`schedule.pool_pump_normal` — 11:00–17:30 daily (all 7 days). Replaces Shelly's internal schedule. + +### Automations + +| ID | Trigger | Condition | Action | +|----|---------|-----------|--------| +| `pool_pump_schedule_on` | schedule → on | — | turn on switch.pool_pump | +| `pool_pump_schedule_off` | schedule → off | pump is on | if conditions_met: set extended_run flag; else turn off pump | +| `pool_pump_early_start` | pv_surplus_available → on | schedule off AND time 08:00–11:00 | turn on pump | +| `pool_pump_early_stop` | pv_surplus_available → off | schedule off AND time 08:00–11:00 AND pump on | turn off pump | +| `pool_pump_extended_run_stop` | conditions_met → off OR extended_run → on | extended_run on AND conditions_met off | turn off pump, clear extended_run flag | + +--- + +## Sensors referenced + +| Entity | Source | +|--------|--------| +| `sensor.energy_pv_surplus` | `packages/energy.yaml` | +| `sensor.victron_battery_soc` | `packages/victron.yaml` (MQTT) | +| `sensor.victron_grid_power_export` | `packages/victron.yaml` (template) | +| `sensor.wheatherstation_outdoor_temperature` | `packages/pergola.yaml` (MQTT) | +| `sensor.mike_redox_potential` | VistaPool integration (UI), device "Mike" | +| `sensor.mike_ph` | VistaPool integration (UI), device "Mike" | +| `sensor.mike_temperature` | VistaPool integration (UI), device "Mike" | + +--- + +## ORP alarm logic + +The VistaPool integration exposes only raw sensor values — no built-in alarm entities. +The alarm is derived in HA: + +```yaml +state: > + {{ states('sensor.mike_redox_potential') | float(999) + < states('input_number.pool_orp_alarm_threshold') | float }} +availability: > + {{ states('sensor.mike_redox_potential') not in ['unavailable', 'unknown'] }} +``` + +- `| float(999)`: if the sensor is transiently unknown, 999 mV is above any threshold → no false alarm. +- `availability:` guard: when VistaPool cloud is offline the alarm becomes `unavailable`, + which blocks `pool_extended_run_conditions_met` availability → extended run cannot start. +- Default threshold: 650 mV. Adjustable at runtime via `input_number.pool_orp_alarm_threshold`. + +--- + +## Verification checklist + +- [ ] Disable Shelly's internal schedule in Shelly web UI +- [ ] `switch.pool_pump` visible in HA and toggleable manually +- [ ] At 11:00: pump turns on via HA log +- [ ] At 17:30, low PV / low SoC / cool temp: pump turns off +- [ ] Before 11:00 with export ≥ 500 W: pump starts early +- [ ] PV drops before 11:00: pump stops again +- [ ] Near 17:30 with all conditions met: pump continues past schedule +- [ ] Clear one condition: pump stops, extended_run flag clears diff --git a/tests/conftest.py b/tests/conftest.py index 93271bc..8a432c6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,6 +56,14 @@ def baseline_inputs(home_assistant: HomeAssistant) -> None: "input_number.pergola_slat_thickness": 3, "input_number.airflow_bypass_efficiency_max": 0.818, "input_number.airflow_bypass_efficiency_min": 0.05, + # Energy package defaults + "input_number.energy_battery_soc_min": 80, + "input_number.energy_min_surplus": 50, + # Pool pump defaults + "input_number.pool_pv_surplus_threshold": 500, + "input_number.pool_battery_soc_min": 80, + "input_number.pool_temp_threshold": 30, + "input_number.pool_orp_alarm_threshold": 650, }.items(): ha.call_action("input_number", "set_value", {"entity_id": entity_id, "value": value}) @@ -192,6 +200,15 @@ def baseline_states(home_assistant: HomeAssistant, baseline_inputs: None) -> Non {"unit_of_measurement": "%", "device_class": "humidity"}) # Heating/cooling indicator (template sensor from another package) ha.set_state("sensor.heating_cooling_indicator", "neutral", {}) + # VistaPool "Mike" device (cloud push integration — absent in CI); seeded above alarm + # threshold so binary_sensor.pool_orp_alarm starts off (no false alarm during unrelated tests) + ha.set_state("sensor.mike_redox_potential", "700", + {"unit_of_measurement": "mV"}) + ha.set_state("sensor.mike_ph", "7.2", {}) + ha.set_state("sensor.mike_temperature", "28.0", + {"unit_of_measurement": "°C", "device_class": "temperature"}) + # Pool schedule — seeded off (outside 11:00-17:30 window) for test baseline + ha.set_state("schedule.pool_pump_normal", "off", {}) # ── Shared time-machine fixtures ───────────────────────────────────────────────────────── diff --git a/tests/test_pool_pump.py b/tests/test_pool_pump.py new file mode 100644 index 0000000..cc68581 --- /dev/null +++ b/tests/test_pool_pump.py @@ -0,0 +1,238 @@ +"""Pool pump package template sensor tests. + +Covers the derived binary sensors in packages/pool_pump.yaml and the shared +energy template sensors in packages/energy.yaml: + + energy.yaml: + sensor.energy_pv_surplus — mirrors victron_grid_power_export + binary_sensor.energy_free_available — surplus > noise floor AND soc >= min + + pool_pump.yaml: + binary_sensor.pool_pv_surplus_available — energy_pv_surplus >= threshold + binary_sensor.pool_extended_run_energy_ok — surplus OR soc >= pool min + binary_sensor.pool_orp_alarm — ORP < alarm threshold + binary_sensor.pool_extended_run_conditions_met — temp AND orp_alarm AND energy_ok + +MQTT broker and VistaPool cloud are absent in CI; sensors are seeded via set_state. +""" + +import pytest +from ha_integration_test_harness import HomeAssistant + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +_ATTRS_W = {"unit_of_measurement": "W", "device_class": "power", "state_class": "measurement"} +_ATTRS_PCT = {"unit_of_measurement": "%", "device_class": "battery", "state_class": "measurement"} +_ATTRS_C = {"unit_of_measurement": "°C", "device_class": "temperature"} +_ATTRS_MV = {"unit_of_measurement": "mV"} + + +def _set_grid_export(ha: HomeAssistant, export_w: float) -> None: + """Drive the Victron grid sensors so that victron_grid_power_export = export_w. + + export_w positive → distributes across L1/L2/L3 as negative (exporting). + export_w zero → all phases at 0. + """ + per_phase = -export_w / 3 + ha.set_state("sensor.victron_grid_l1_power", str(round(per_phase, 1)), _ATTRS_W) + ha.set_state("sensor.victron_grid_l2_power", str(round(per_phase, 1)), _ATTRS_W) + ha.set_state("sensor.victron_grid_l3_power", str(round(per_phase, 1)), _ATTRS_W) + + +def _set_soc(ha: HomeAssistant, soc: float) -> None: + ha.set_state("sensor.victron_battery_soc", str(soc), _ATTRS_PCT) + + +def _set_orp(ha: HomeAssistant, orp_mv: float) -> None: + ha.set_state("sensor.mike_redox_potential", str(orp_mv), _ATTRS_MV) + + +def _set_outdoor_temp(ha: HomeAssistant, temp_c: float) -> None: + ha.set_state("sensor.wheatherstation_outdoor_temperature", str(temp_c), _ATTRS_C) + + +# ── sensor.energy_pv_surplus ────────────────────────────────────────────────── + +def test_energy_pv_surplus_export(home_assistant: HomeAssistant) -> None: + """Grid exporting 600 W → energy_pv_surplus = 600.""" + _set_grid_export(home_assistant, 600) + home_assistant.assert_entity_state( + "sensor.energy_pv_surplus", + lambda s: abs(float(s) - 600) < 1, + timeout=5, + ) + + +def test_energy_pv_surplus_import(home_assistant: HomeAssistant) -> None: + """Grid importing 400 W (positive phases) → energy_pv_surplus = 0 (no export).""" + # Positive grid power = importing; export half-wave is clamped to 0 + ha = home_assistant + ha.set_state("sensor.victron_grid_l1_power", "133", _ATTRS_W) + ha.set_state("sensor.victron_grid_l2_power", "133", _ATTRS_W) + ha.set_state("sensor.victron_grid_l3_power", "134", _ATTRS_W) + ha.assert_entity_state( + "sensor.energy_pv_surplus", + lambda s: float(s) == 0.0, + timeout=5, + ) + + +# ── binary_sensor.energy_free_available ─────────────────────────────────────── + +def test_energy_free_available_on(home_assistant: HomeAssistant) -> None: + """Surplus > 50 W (noise floor) AND soc >= 80 % → energy_free_available on.""" + _set_grid_export(home_assistant, 200) # > energy_min_surplus=50 + _set_soc(home_assistant, 85) # >= energy_battery_soc_min=80 + home_assistant.assert_entity_state("binary_sensor.energy_free_available", "on", timeout=5) + + +def test_energy_free_available_surplus_below_floor(home_assistant: HomeAssistant) -> None: + """Surplus at noise floor or below → energy_free_available off.""" + _set_grid_export(home_assistant, 30) # <= energy_min_surplus=50 + _set_soc(home_assistant, 90) + home_assistant.assert_entity_state("binary_sensor.energy_free_available", "off", timeout=5) + + +def test_energy_free_available_soc_too_low(home_assistant: HomeAssistant) -> None: + """Good surplus but battery soc below minimum → energy_free_available off.""" + _set_grid_export(home_assistant, 500) + _set_soc(home_assistant, 70) # < energy_battery_soc_min=80 + home_assistant.assert_entity_state("binary_sensor.energy_free_available", "off", timeout=5) + + +# ── binary_sensor.pool_pv_surplus_available ─────────────────────────────────── + +def test_pool_pv_surplus_available_on(home_assistant: HomeAssistant) -> None: + """PV export >= 500 W (pool threshold) → pool_pv_surplus_available on.""" + _set_grid_export(home_assistant, 600) + home_assistant.assert_entity_state("binary_sensor.pool_pv_surplus_available", "on", timeout=5) + + +def test_pool_pv_surplus_available_at_threshold(home_assistant: HomeAssistant) -> None: + """PV export exactly at threshold → on.""" + _set_grid_export(home_assistant, 500) + home_assistant.assert_entity_state("binary_sensor.pool_pv_surplus_available", "on", timeout=5) + + +def test_pool_pv_surplus_available_below_threshold(home_assistant: HomeAssistant) -> None: + """PV export below 500 W → pool_pv_surplus_available off.""" + _set_grid_export(home_assistant, 300) + home_assistant.assert_entity_state("binary_sensor.pool_pv_surplus_available", "off", timeout=5) + + +def test_pool_pv_surplus_available_no_export(home_assistant: HomeAssistant) -> None: + """No export at all → pool_pv_surplus_available off.""" + _set_grid_export(home_assistant, 0) + home_assistant.assert_entity_state("binary_sensor.pool_pv_surplus_available", "off", timeout=5) + + +# ── binary_sensor.pool_extended_run_energy_ok ───────────────────────────────── + +def test_pool_extended_run_energy_ok_via_surplus(home_assistant: HomeAssistant) -> None: + """PV surplus available (high export), low soc → energy_ok on via surplus.""" + _set_grid_export(home_assistant, 600) + _set_soc(home_assistant, 50) # < pool_battery_soc_min=80 + home_assistant.assert_entity_state( + "binary_sensor.pool_extended_run_energy_ok", "on", timeout=5 + ) + + +def test_pool_extended_run_energy_ok_via_soc(home_assistant: HomeAssistant) -> None: + """Low PV export but soc >= 80 % → energy_ok on via battery.""" + _set_grid_export(home_assistant, 0) + _set_soc(home_assistant, 85) # >= pool_battery_soc_min=80 + home_assistant.assert_entity_state( + "binary_sensor.pool_extended_run_energy_ok", "on", timeout=5 + ) + + +def test_pool_extended_run_energy_ok_off(home_assistant: HomeAssistant) -> None: + """No PV surplus AND soc < 80 % → energy_ok off.""" + _set_grid_export(home_assistant, 0) + _set_soc(home_assistant, 50) + home_assistant.assert_entity_state( + "binary_sensor.pool_extended_run_energy_ok", "off", timeout=5 + ) + + +# ── binary_sensor.pool_orp_alarm ────────────────────────────────────────────── + +def test_pool_orp_alarm_on(home_assistant: HomeAssistant) -> None: + """ORP below threshold (650 mV) → alarm on.""" + _set_orp(home_assistant, 600) + home_assistant.assert_entity_state("binary_sensor.pool_orp_alarm", "on", timeout=5) + + +def test_pool_orp_alarm_at_threshold(home_assistant: HomeAssistant) -> None: + """ORP exactly at threshold → NOT alarming (alarm requires strictly less than).""" + _set_orp(home_assistant, 650) + home_assistant.assert_entity_state("binary_sensor.pool_orp_alarm", "off", timeout=5) + + +def test_pool_orp_alarm_off(home_assistant: HomeAssistant) -> None: + """ORP above threshold → alarm off.""" + _set_orp(home_assistant, 720) + home_assistant.assert_entity_state("binary_sensor.pool_orp_alarm", "off", timeout=5) + + +def test_pool_orp_alarm_unavailable_propagates(home_assistant: HomeAssistant) -> None: + """VistaPool sensor unavailable → pool_orp_alarm becomes unavailable.""" + home_assistant.set_state("sensor.mike_redox_potential", "unavailable", _ATTRS_MV) + home_assistant.assert_entity_state( + "binary_sensor.pool_orp_alarm", "unavailable", timeout=5 + ) + + +# ── binary_sensor.pool_extended_run_conditions_met ──────────────────────────── + +def test_pool_extended_run_conditions_all_met(home_assistant: HomeAssistant) -> None: + """temp > 30, ORP alarm, PV surplus → conditions_met on.""" + _set_outdoor_temp(home_assistant, 32) + _set_orp(home_assistant, 600) # alarm on + _set_grid_export(home_assistant, 600) # surplus on + home_assistant.assert_entity_state( + "binary_sensor.pool_extended_run_conditions_met", "on", timeout=5 + ) + + +def test_pool_extended_run_conditions_temp_too_low(home_assistant: HomeAssistant) -> None: + """temp <= 30 → conditions_met off even if ORP and energy are met.""" + _set_outdoor_temp(home_assistant, 29) + _set_orp(home_assistant, 600) + _set_grid_export(home_assistant, 600) + home_assistant.assert_entity_state( + "binary_sensor.pool_extended_run_conditions_met", "off", timeout=5 + ) + + +def test_pool_extended_run_conditions_orp_ok(home_assistant: HomeAssistant) -> None: + """ORP above alarm threshold → conditions_met off (ORP alarm off).""" + _set_outdoor_temp(home_assistant, 32) + _set_orp(home_assistant, 720) # alarm off + _set_grid_export(home_assistant, 600) + home_assistant.assert_entity_state( + "binary_sensor.pool_extended_run_conditions_met", "off", timeout=5 + ) + + +def test_pool_extended_run_conditions_no_energy(home_assistant: HomeAssistant) -> None: + """No PV surplus and low soc → energy_ok off → conditions_met off.""" + _set_outdoor_temp(home_assistant, 32) + _set_orp(home_assistant, 600) + _set_grid_export(home_assistant, 0) + _set_soc(home_assistant, 50) # < pool_battery_soc_min=80 + home_assistant.assert_entity_state( + "binary_sensor.pool_extended_run_conditions_met", "off", timeout=5 + ) + + +def test_pool_extended_run_conditions_battery_saves_it(home_assistant: HomeAssistant) -> None: + """No PV surplus but soc >= 80 % → energy_ok on → conditions_met on (if temp+ORP met).""" + _set_outdoor_temp(home_assistant, 32) + _set_orp(home_assistant, 600) + _set_grid_export(home_assistant, 0) + _set_soc(home_assistant, 85) # >= pool_battery_soc_min=80 + home_assistant.assert_entity_state( + "binary_sensor.pool_extended_run_conditions_met", "on", timeout=5 + )