diff --git a/.gitignore b/.gitignore index 276827b0..526f183a 100755 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,9 @@ config/custom_components/* !config/custom_components/alexa_camera_compat/ !config/custom_components/alexa_camera_compat/** config/custom_components/alexa_camera_compat/__pycache__/ +!config/custom_components/tesla_charge_guard/ +!config/custom_components/tesla_charge_guard/** +config/custom_components/tesla_charge_guard/__pycache__/ config/www/community config/www/ookla_speedtest/ config/.cache/ diff --git a/config/custom_components/tesla_charge_guard/README.md b/config/custom_components/tesla_charge_guard/README.md new file mode 100644 index 00000000..f252988e --- /dev/null +++ b/config/custom_components/tesla_charge_guard/README.md @@ -0,0 +1,51 @@ +# Tesla home charge verification + +This small read-only adapter exposes Tesla Fleet's **source sample timestamps** +and observes live JuiceBox MQTT current/status reports. It reuses the existing +Tesla Fleet connection and credentials. It sends no vehicle or charger commands. +Native Home Assistant scripts in the vehicle package own protective stops, +notifications, investigation dispatch, and restart review. + +Before a protective command, the package requests fresh Tesla data to check +vehicle scope. A new home charger session with missing Tesla data gets an +incident refresh within a minute. A missing vehicle identity still prompts +investigation/critical home-charger checks, but does not authorize stopping a +vehicle elsewhere. A fresh away or DC report vetoes commands even when the +current field is missing. A fresh zero-current home-charger report can clear +the home incident while the vehicle is charging elsewhere. + +`tesla_charge_guard.snapshot` returns an assessment and fires +`tesla_charge_guard_report`. `refresh: true` requests one refresh with a 25-second +timeout; it does not wake the car. A sleeping/rate-limited vehicle can return +cached data, which remains unverified. Tesla samples expire after 120 seconds, +JuiceBox current/status reports after 60 seconds. Retained MQTT messages are +ignored. Fresh away/DC reports veto home stop commands. Source data with missing +current, invalid current, unknown charging state, or a future timestamp is not +accepted as evidence of stopped charging. + +The operational guardrail is 18 A around the intended 16 A home rate. This is +not a replacement for electrical protection. A successful stop command is not +proof of a stopped vehicle; the package requires a fresh zero-current report. + +The adapter uses the installed Tesla Fleet runtime coordinator, so its contract +must be checked after Home Assistant upgrades. If the vehicle cannot be found, +the result becomes unverified. It retains no credentials, VIN, or coordinates +in the returned snapshot. MQTT acquisition timestamps are intentionally kept +in memory; after a restart, a new non-retained report is required. + +The package's restoring session helper remembers a verified home connection +across power loss or HA restarts. It conveys last-known scope, never freshness; +only a fresh unplugged/away report ends that scope. This allows a five-minute +protective stop even while the Tesla integration is still reconnecting. + +The Car view exposes the restart-review helper. Clearing it acknowledges review +and permits subsequent home charging; it does not start charging. Confirm the +home current limit is 16 A and review the incident before clearing it. The monitoring +warning and restart-review warning remain separate from an unresolved critical +stop. Source snapshot sensors/events are excluded from recorder noise; aggregate +incident sensors and curated JUICEBOX log entries remain recorded. + +Validation: `python tools/test_tesla_charge_guard.py`; run the adapter contract +test in an environment with Home Assistant installed. Check the full HA config +before loading the adapter. Loading a new custom integration requires an HA +restart; subsequent package-only edits can use normal scripts/templates reloads. diff --git a/config/custom_components/tesla_charge_guard/__init__.py b/config/custom_components/tesla_charge_guard/__init__.py new file mode 100644 index 00000000..962634a2 --- /dev/null +++ b/config/custom_components/tesla_charge_guard/__init__.py @@ -0,0 +1,116 @@ +"""Read-only source verification for the package's native HA charging actions.""" + +import asyncio +from time import time + +import voluptuous as vol + +from homeassistant.components import mqtt, zone +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.typing import ConfigType + +from .policy import classify, number + +DOMAIN = "tesla_charge_guard" +EVENT = "tesla_charge_guard_report" +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({ + vol.Required("entity_id"): cv.entity_id, + vol.Required("mqtt_prefix"): mqtt.valid_subscribe_topic, + vol.Optional("current_limit", default=18): vol.All(vol.Coerce(float), vol.Range(min=1, max=48)), +})}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Expose coordinator sample times without adding credentials or controls.""" + settings = config[DOMAIN] + juicebox = {} + refresh_lock = asyncio.Lock() + + def vehicle(): + registry_entry = er.async_get(hass).async_get(settings["entity_id"]) + if registry_entry is None or registry_entry.platform != "tesla_fleet": + raise ServiceValidationError("Configured entity must belong to Tesla Fleet") + entry = hass.config_entries.async_get_entry(registry_entry.config_entry_id) + runtime = getattr(entry, "runtime_data", None) + for item in getattr(runtime, "vehicles", []): + if registry_entry.unique_id.startswith(f"{item.vin}-"): + return item + raise ServiceValidationError("Tesla Fleet vehicle is not loaded") + + def assessment(): + tesla = {} + try: + data = vehicle().coordinator.data or {} + latitude = data.get("drive_state_latitude") + longitude = data.get("drive_state_longitude") + home = hass.states.get("zone.home") + at_home = (zone.in_zone(home, latitude, longitude) + if home and latitude is not None and longitude is not None else None) + charging = str(data.get("charge_state_charging_state", "")).lower() + tesla = { + "current": data.get("charge_state_charger_actual_current"), + "charging": charging, + "sample_time": (number(data.get("charge_state_timestamp")) or 0) / 1000, + "location_time": (number(data.get("drive_state_timestamp")) or 0) / 1000, + "at_home": at_home, + "dc": data.get("charge_state_fast_charger_present"), + "plugged": charging in {"charging", "starting", "stopped", "complete", "no_power"}, + } + except (ServiceValidationError, AttributeError, TypeError, ValueError): + pass + session = hass.states.is_state("input_boolean.tesla_home_charge_session_active", "on") + return classify(tesla, juicebox, time(), settings["current_limit"], session) + + @callback + def publish(): + hass.bus.async_fire(EVENT, assessment()) + + @callback + def receive(message): + # Retained startup/reconnect values cannot establish current draw now. + if message.retain: + return + field = "current" if "/Current/" in message.topic else "status" + value = number(message.payload) if field == "current" else str(message.payload) + if value is None: + # Invalid reports revoke the old field instead of extending its life. + juicebox.pop(field, None) + juicebox.pop(f"{field}_time", None) + else: + juicebox[field] = value + juicebox[f"{field}_time"] = time() + publish() + + removers = [] + for field in ("Current", "Status"): + removers.append(await mqtt.async_subscribe( + hass, f"{settings['mqtt_prefix']}/{field}/state", receive)) + + async def snapshot(call: ServiceCall): + error = None + if call.data["refresh"]: + try: + async with refresh_lock, asyncio.timeout(25): + await vehicle().coordinator.async_refresh() + except (ServiceValidationError, TimeoutError) as err: + error = type(err).__name__ + result = assessment() + if error: + result["refresh_error"] = error + hass.bus.async_fire(EVENT, result) + return result + + hass.services.async_register(DOMAIN, "snapshot", snapshot, + schema=vol.Schema({vol.Optional("refresh", default=False): cv.boolean}), + supports_response=SupportsResponse.OPTIONAL) + + @callback + def cleanup(_event): + for remove in removers: + remove() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) + return True diff --git a/config/custom_components/tesla_charge_guard/manifest.json b/config/custom_components/tesla_charge_guard/manifest.json new file mode 100644 index 00000000..a8f3403c --- /dev/null +++ b/config/custom_components/tesla_charge_guard/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tesla_charge_guard", + "name": "Tesla Home Charge Verification", + "codeowners": ["@CCOSTAN"], + "dependencies": ["mqtt"], + "after_dependencies": ["tesla_fleet"], + "documentation": "https://github.com/CCOSTAN/Home-AssistantConfig", + "integration_type": "service", + "version": "1.0.0" +} diff --git a/config/custom_components/tesla_charge_guard/policy.py b/config/custom_components/tesla_charge_guard/policy.py new file mode 100644 index 00000000..3373c2fa --- /dev/null +++ b/config/custom_components/tesla_charge_guard/policy.py @@ -0,0 +1,103 @@ +"""Classify measured charging data without treating cached values as fresh.""" + +from math import isfinite + + +def number(value): + """Reject missing, boolean, negative, and non-finite measurements.""" + try: + result = float(value) + except (TypeError, ValueError): + return None + return result if not isinstance(value, bool) and isfinite(result) and result >= 0 else None + + +def fresh(timestamp, now, max_age): + """Accept source timestamps with at most five seconds of clock skew.""" + timestamp = number(timestamp) + return timestamp is not None and -5 <= now - timestamp <= max_age + + +def classify(tesla, juicebox, now, limit=18, session_active=False): + """Return a conservative, privacy-safe home charging assessment.""" + tc = number(tesla.get("current")) + jc = number(juicebox.get("current")) + ts = str(tesla.get("charging", "")).lower() + js = str(juicebox.get("status", "")).lower() + tesla_sample_fresh = fresh(tesla.get("sample_time"), now, 120) + tf = tesla_sample_fresh and tc is not None + jf = (fresh(juicebox.get("current_time"), now, 60) + and fresh(juicebox.get("status_time"), now, 60) and jc is not None) + location_fresh = fresh(tesla.get("location_time"), now, 120) + at_home = tesla.get("at_home") + dc = tesla.get("dc") + # A fresh away/DC report vetoes vehicle commands, including cached home hints. + away = (location_fresh and at_home is False) or (tesla_sample_fresh and dc is True) + home = not away and ((at_home is True and dc is False) or session_active) + # The newest known source owns session context. Old Tesla charging data must + # not resurrect a session after a newer JuiceBox report confirmed unplugged. + jb_latest = (number(juicebox.get("current_time")) or 0) >= (number(tesla.get("sample_time")) or 0) + active = jc is not None and jc > 0 if jb_latest else ts in {"charging", "starting"} + connected = js not in {"", "unknown", "unavailable", "unplugged"} + context = active or connected if jb_latest else active or tesla.get("plugged") is True + home_session = not away and ((home and (session_active or context)) or (jb_latest and jc is not None and context)) + result = { + "status": "unverified", "actual_current": None, "source": "none", + "tesla_fresh": tf, "juicebox_fresh": jf, + "home_session": home_session, "stop_allowed": home and home_session, + "session_observed": None, + "checked_at": now, "sample_time": 0, + "tesla_sample_time": tesla.get("sample_time", 0), + "juicebox_sample_time": juicebox.get("current_time", 0), + "reason": "No fresh charging report; cached values cannot verify safety.", + } + if away and not (jf and jc > limit): + result.update(status="away", stop_allowed=False, home_session=False, session_observed=False, + reason="Fresh away or DC charging report; home stop commands blocked.") + # Charging elsewhere must not prevent a recovered home charger from + # clearing its own incident. This still never permits a vehicle command. + if jf and jc == 0 and js in {"unplugged", "plugged in", "plugged", "connected", "standby", "ready"}: + result.update(status="stopped", actual_current=0, source="juicebox", + sample_time=juicebox["current_time"], + reason="Fresh home charger zero current confirms the home incident is stopped.") + return result + # Either live source can expose excessive draw; a lower reading cannot mask it. + excessive = [] + if jf and jc > limit: + excessive.append((jc, "juicebox", juicebox["current_time"])) + if tf and home and ts in {"charging", "starting"} and tc > limit: + excessive.append((tc, "tesla", tesla["sample_time"])) + if excessive: + current, source, timestamp = max(excessive) + result.update(status="excessive", actual_current=current, source=source, + sample_time=timestamp, stop_allowed=home, home_session=True, + session_observed=True if home else None, + reason=f"Fresh measured home current exceeds {limit:g} A.") + return result + if jf and jc == 0 and tf and ts in {"charging", "starting"} and tc > 0: + result.update(home_session=home, stop_allowed=home, + reason="Fresh sources disagree about whether charging is active.") + return result + # A fresh active report prevents an older/fresh conflicting zero from clearing. + if (jf and jc > 0) or (tf and ts in {"charging", "starting"}): + if home and ((jf and 0 < jc <= limit) or + (tf and ts in {"charging", "starting"} and 0 < tc <= limit)): + source = "juicebox" if jf and jc > 0 else "tesla" + result.update(status="charging_safe", source=source, + home_session=True, stop_allowed=True, session_observed=True, + actual_current=jc if source == "juicebox" else tc, + sample_time=juicebox["current_time"] if source == "juicebox" else tesla["sample_time"], + reason="Fresh home charging current is within the guardrail.") + return result + if ((jf and jc == 0 and js in {"unplugged", "plugged in", "plugged", "connected", "standby", "ready"}) or + (tf and tc == 0 and ts in {"stopped", "complete", "disconnected", "no_power"})): + source = "juicebox" if jf and jc == 0 else "tesla" + result.update(status="stopped", actual_current=0, source=source, + sample_time=juicebox["current_time"] if source == "juicebox" else tesla["sample_time"], + stop_allowed=False, reason="Fresh measured zero current confirms charging is stopped.") + # Keep a connected stopped session in scope: charging can resume after + # power returns. Only a fresh unplugged/away report ends the durable lease. + unplugged = js == "unplugged" if source == "juicebox" else ts == "disconnected" + result.update(home_session=home and not unplugged, + session_observed=False if unplugged else True if home else None) + return result diff --git a/config/custom_components/tesla_charge_guard/services.yaml b/config/custom_components/tesla_charge_guard/services.yaml new file mode 100644 index 00000000..4257a7a9 --- /dev/null +++ b/config/custom_components/tesla_charge_guard/services.yaml @@ -0,0 +1,18 @@ +###################################################################### +# @CCOSTAN - Follow Me on X +# For more info visit https://www.vcloudinfo.com/click-here +# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig +# ------------------------------------------------------------------- +# Tesla Home Charge Verification - Read-only source snapshot +# ------------------------------------------------------------------- +###################################################################### +snapshot: + name: Verify home charging + description: Check source timestamps and measured current. Does not wake the car or change charging. + fields: + refresh: + name: Refresh Tesla data + description: Request a bounded Tesla Fleet refresh; cached responses remain unverified. + default: false + selector: + boolean: diff --git a/config/dashboards/infrastructure/partials/car_sections.yaml b/config/dashboards/infrastructure/partials/car_sections.yaml index ccfebb3e..c50aebbb 100644 --- a/config/dashboards/infrastructure/partials/car_sections.yaml +++ b/config/dashboards/infrastructure/partials/car_sections.yaml @@ -147,7 +147,8 @@ variables: status_severity: > [[[ - if (states['binary_sensor.juicepassproxy_monitoring_degraded']?.state === 'on') return 'critical'; + if (states['binary_sensor.tesla_home_charge_critical']?.state === 'on') return 'critical'; + if (states['binary_sensor.juicepassproxy_monitoring_degraded']?.state === 'on') return 'warning'; return entity?.state === 'online' ? 'healthy' : 'warning'; ]]] state_display: > @@ -157,6 +158,22 @@ ? 'NO REPORTS' : 'VERIFYING'; ]]] + - type: custom:button-card + template: bearstone_infra_status_kpi + entity: input_boolean.tesla_home_charge_restart_required + name: Charging review + icon: mdi:shield-car + variables: + status_severity: > + [[[ + if (states['binary_sensor.tesla_home_charge_critical']?.state === 'on') return 'critical'; + return entity?.state === 'on' ? 'warning' : 'healthy'; + ]]] + state_display: > + [[[ return entity?.state === 'on' ? 'REVIEW BEFORE RESUME' : 'CLEAR'; ]]] + tap_action: + action: more-info + # ------------------------------------------------------------------- # Current vehicle, tires, and charging telemetry # ------------------------------------------------------------------- diff --git a/config/dashboards/infrastructure/partials/home_sections.yaml b/config/dashboards/infrastructure/partials/home_sections.yaml index 2ac94e8f..a070786c 100644 --- a/config/dashboards/infrastructure/partials/home_sections.yaml +++ b/config/dashboards/infrastructure/partials/home_sections.yaml @@ -70,6 +70,7 @@ ]]] raw_suffix: ACTIVE warning_entity_ids: + - binary_sensor.tesla_home_charge_attention - binary_sensor.infra_wan_sustained_degradation - binary_sensor.rheem_wh_telemetry_stale - binary_sensor.docker_host_maintenance_attention @@ -81,7 +82,7 @@ - binary_sensor.infra_compute_degraded - binary_sensor.infra_storage_degraded - binary_sensor.infra_external_monitoring_degraded - - binary_sensor.juicepassproxy_monitoring_degraded + - binary_sensor.tesla_home_charge_critical - binary_sensor.rheem_wh_active_alert - binary_sensor.garage_phyn_leak_alert @@ -147,12 +148,15 @@ ]; const battery = Number(entity?.state); const tires = tireIds.map((id) => Number(states[id]?.state)).filter(Number.isFinite); - if (states['binary_sensor.juicepassproxy_monitoring_degraded']?.state === 'on') return 'critical'; + if (states['binary_sensor.tesla_home_charge_critical']?.state === 'on') return 'critical'; + if (states['binary_sensor.tesla_home_charge_attention']?.state === 'on') return 'warning'; if (!Number.isFinite(battery) || tires.length !== tireIds.length) return 'unknown'; return Math.min(...tires) < 38.5 ? 'critical' : 'healthy'; ]]] state_display: > [[[ + if (states['binary_sensor.tesla_home_charge_critical']?.state === 'on') return 'CHECK CHARGING'; + if (states['input_boolean.tesla_home_charge_restart_required']?.state === 'on') return 'RESTART REVIEW'; if (states['binary_sensor.juicepassproxy_monitoring_degraded']?.state === 'on') return 'MONITOR CHECK'; const battery = Number(entity?.state); if (!Number.isFinite(battery)) return 'NO DATA'; @@ -523,13 +527,24 @@ - type: custom:button-card template: bearstone_infra_alert_row - entity: binary_sensor.juicepassproxy_monitoring_degraded - name: JuiceBox monitoring unavailable + entity: binary_sensor.tesla_home_charge_attention + name: Home charging needs attention icon: mdi:ev-station-off variables: alert_kind: binary_on - exception_detail: NO LIVE REPORTS - CHECK THE TESLA AND CHARGER - state_display: '[[[ return "NO DATA"; ]]]' + warning_only: true + critical_entity_ids: + - binary_sensor.tesla_home_charge_critical + exception_detail: > + [[[ + if (states['binary_sensor.tesla_home_charge_critical']?.state === 'on') + return 'CHECK THE TESLA - CHARGING STOP NOT YET VERIFIED'; + if (states['input_boolean.tesla_home_charge_restart_required']?.state === 'on') + return 'CHARGING PAUSED - REVIEW BEFORE RESUMING'; + return 'MONITORING REPORTS MISSING - INVESTIGATION REQUESTED'; + ]]] + state_display: > + [[[ return states['binary_sensor.tesla_home_charge_critical']?.state === 'on' ? 'CHECK' : 'REVIEW'; ]]] tap_action: action: navigate navigation_path: /dashboard-infrastructure/car diff --git a/config/logbook.yaml b/config/logbook.yaml index f8fe0b02..740a48e6 100644 --- a/config/logbook.yaml +++ b/config/logbook.yaml @@ -11,6 +11,7 @@ # Notes: Filters vcloudinfo availability chatter plus location/weather noise and raw Glances host telemetry. # Notes: L10s status/room telemetry is hidden; curated VACUUM logbook entries remain visible. # Notes: Tesla Fleet Spaceship telemetry is hidden; TeslaMate keeps the historical car record. +# Notes: Charging source snapshots stay hidden; curated JUICEBOX incident events remain visible. # Notes: Person/location changes and Life360 online status stay out of Activity; recorder keeps location history. # Notes: Raw Portainer container/stack status telemetry is hidden; Docker aggregate outage sensors and curated DOCKER events remain. # Notes: Mobile app phone telemetry stays out of Activity; phone notify services are unaffected. @@ -86,6 +87,7 @@ exclude: - "*alarm_panel_2*" entities: - automation.cuckoo_clock + - sensor.tesla_home_charge_guard - automation.detect_lights_and_adjust_the_brightness_when_turned_on_based_on_time - automation.docker_state_sync_repairs_dynamic - automation.docker_telemetry_template_refresh diff --git a/config/packages/README.md b/config/packages/README.md index 7f44fc0d..09b61723 100755 --- a/config/packages/README.md +++ b/config/packages/README.md @@ -78,7 +78,7 @@ After Home Assistant starts, Kitchen Show waits three minutes for the initial st | [![YAML source: maintenance_log](https://img.shields.io/static/v1?label=YAML&message=maintenance_log&color=lightgrey&logo=github&logoColor=181717)](maintenance_log.yaml) | Webhook-based water softener salt maintenance logging with idempotent event handling, Activity Feed logging, parser-safe refill cadence history, and dashboard-ready template sensors. [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/_OqTv9i-jAs) [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2026/07/home-assistant-water-softener-salt-automation.html) | `automation.maintenance_log_joanna_webhook_ingest`, `sensor.water_softener_salt_average_days_between_refills`, `sensor.water_softener_salt_refill_count`, `sensor.water_softener_salt_total_added` | | [![YAML source: kiosk_tablet](https://img.shields.io/static/v1?label=YAML&message=kiosk_tablet&color=lightgrey&logo=github&logoColor=181717)](kiosk_tablet.yaml) | Shares Garage-then-Front-Door priority plus reusable Bedroom header/alert state while keeping each display purpose-built. The Garage view now remains active for three minutes after both doors close before either kiosk returns to its default dashboard. Bedroom retains Fully wake/drift recovery and SleepIQ screen behavior around a clock-free night-watch display; Kitchen retains its clock/forecast, rotating household context, native camera overlays, Kiosk Satellite idle dimming, and an event-driven household policy that corrects master volume to 13% at 08:00 and startup, and invokes the Dim screensaver while `group.bed` is on or the family is away. Nearby rain or lightning starts one five-minute NOAA radar takeover while the sun is above the horizon; afterward, the rotating Storms Approaching banner can replay the latest 20-minute loop while the event remains active. Actionable banner slides reuse one transient detail timer for NWS instructions, the front-door package view, a person-centered family map, open-entry details, Phyn water status with a confirmed valve-restore action, and hot-water recovery status. Sunset restores the ambient wall immediately. True idle panel-off remains disabled on the Crown LineageOS device until it has a reliable motion-wake input. Fully availability/page/app/power checks plus Kitchen connectivity, current-page, and screenshot freshness keep the Systems Kiosks tab truthful; a reachable Kitchen display remains online while stale session timestamps remain visible only in the telemetry panel. Available Kitchen updates remain advisory telemetry, while stable attention attributes preserve useful outage transitions without recording every heartbeat. At 07:00, Home Assistant requests the pending signed installer and waits two minutes for staging before dispatching Joanna to finish the Android confirmation and verify the Kitchen Show returns to its dashboard; an unsuccessful installation is retried at 07:00 the following day until the update entity confirms success. [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/ChgEu0IDWzc) | `binary_sensor.kiosk_garage_camera_active`, `sensor.kiosk_active_camera_view`, `sensor.bedroom_kiosk_header`, `sensor.kiosk_household_alert`, `binary_sensor.kiosk_rain_radar_weather_event`, `binary_sensor.kiosk_rain_radar_active`, `timer.kitchen_kiosk_radar_display`, `input_text.kitchen_kiosk_detail_view`, `timer.kitchen_kiosk_detail_display`, `script.kitchen_kiosk_show_event`, `script.kitchen_kiosk_show_detail`, `script.kitchen_kiosk_restore_phyn_water`, `binary_sensor.bedroom_kiosk_needs_attention`, `binary_sensor.kiosk_devices_need_attention`, `sensor.alarm_panel_1_current_page`, `button.alarm_panel_1_load_start_url`, `binary_sensor.kitchen_kiosk_should_be_on`, `number.kitchen_show_volume_2`, `light.kitchen_show_screen_2`, `button.kitchen_show_postpone_screensaver_2`, `button.kitchen_show_take_screenshot_2`, `sensor.kitchen_show_active_camera_view_2`, `automation.kitchen_kiosk_satellite_update_joanna_dispatch` | | [![YAML source: powerwall](https://img.shields.io/static/v1?label=YAML&message=powerwall&color=lightgrey&logo=github&logoColor=181717)](powerwall.yaml) | Track Tesla Powerwall grid status, keep a restart-safe outage anchor, publish monitor-only staged resilience recommendations from Home Assistant, handle immediate household load shedding, and alert if the Powerwall stays low after grid power returns. [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/hR_0lFEE2bA) [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2026/06/tesla-powerwall-home-assistant-watchdog.html) | `binary_sensor.powerwall_grid_status`, `sensor.powerwall_*`, `input_datetime.power_resilience_outage_started`, `sensor.power_resilience_recommended_stage`, `script.notify_live_activity`, `repairs.create` | -| [![YAML source: tesla_model_y](https://img.shields.io/static/v1?label=YAML&message=tesla_model_y&color=lightgrey&logo=github&logoColor=181717)](tesla_model_y.yaml) | Remind the garage and parents to plug in the Model Y after a low-battery arrival, alert both parents when a reported tire drops below 38.5 PSI, and issue one critical parents push plus a garage announcement when persisted JuiceBox current reaches 20 A. A restoring latch prevents repeat current alarms. A missing JuiceBox MQTT heartbeat dispatches Joanna after three minutes and raises a critical Systems alert after five minutes if reports remain absent; fresh reports clear the alert. Portainer status remains diagnostic. Plug-in alerts snooze for 24 hours; tire alerts and Joanna's morning-briefing action item share 1-day and 3-day snoozes. [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/hDNyZfTfTtE) [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2026/09/home-assistant-tesla-tire-pressure-alerts.html) | `sensor.spaceship_battery_level`, `sensor.tire_pressure_*`, `sensor.juicebox_current_last_known`, `sensor.juicepassproxy_mqtt_heartbeat`, `binary_sensor.juicepassproxy_investigation_needed`, `binary_sensor.juicepassproxy_monitoring_degraded`, `switch.spaceship_charge`, `cover.large_garage_door`, `input_boolean.tesla_high_charge_rate_alert_active`, `input_datetime.tesla_model_y_*`, `input_datetime.tesla_tire_pressure_snooze_until`, `notify.alexa_media_garage`, `script.notify_engine`, `script.joanna_dispatch` | +| [![YAML source: tesla_model_y](https://img.shields.io/static/v1?label=YAML&message=tesla_model_y&color=lightgrey&logo=github&logoColor=181717)](tesla_model_y.yaml) | Keeps garage-driven plug-in/tire reminders and durable dashboard snapshots. Fresh actual home draw above 18 A requests a native Tesla stop, critical push, and investigation; the intended 16 A limit stays unchanged. Source timestamps reject cached Tesla data and retained MQTT. Missing reports dispatch investigation at 3 minutes. Unverified home sessions pause at 5 minutes; safe/stopped sessions receive a quiet monitoring warning at 15 minutes. Fresh post-stop zero current clears the critical incident. A restoring review latch requires human acknowledgment before home charging resumes; no start commands are issued. Monitoring recovery clears its own tagged alert. Plug-in and tire snoozes remain available. [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/hDNyZfTfTtE) [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2026/09/home-assistant-tesla-tire-pressure-alerts.html) | `sensor.tesla_home_charge_guard`, `binary_sensor.tesla_home_charge_critical`, `binary_sensor.tesla_home_charge_attention`, `sensor.juicepassproxy_mqtt_heartbeat`, `binary_sensor.juicepassproxy_investigation_needed`, `binary_sensor.juicepassproxy_monitoring_degraded`, `switch.spaceship_charge`, `input_boolean.tesla_home_charge_restart_required`, `input_boolean.tesla_high_charge_rate_alert_active`, `script.tesla_home_charge_protect`, `script.notify_engine`, `script.joanna_dispatch` | | [![YAML source: vacuum](https://img.shields.io/static/v1?label=YAML&message=vacuum&color=lightgrey&logo=github&logoColor=181717)](vacuum.yaml) | Dreame away-only vacuum orchestration with room tracking, sweep/mop phases, a four-day start guard, family-return task cancellation, push alerts, and Alexa one-off room cleans. | `input_select.l10s_vacuum_phase`, `sensor.l10s_vacuum_error`, `repairs.create` | | [![YAML source: hass_agent_homepc](https://img.shields.io/static/v1?label=YAML&message=hass_agent_homepc&color=lightgrey&logo=github&logoColor=181717)](hass_agent_homepc.yaml) | Mirrors PC lock/unlock state to the office lamp and wakes `CARLO-HOMEPC` on workday morning bed exits. | `sensor.carlo_homepc_carlo_homepc_sessionstate`, `button.carlo_home`, `switch.office_lamp_switch` | | [![YAML source: sleepiq](https://img.shields.io/static/v1?label=YAML&message=sleepiq&color=lightgrey&logo=github&logoColor=181717)](sleepiq.yaml) | Sleep Number presence/snore automations plus a zero-filtered 14-day score summary; Overview Health keeps the detailed scores, vitals, pressure, and bed controls. | `group.bed`, `binary_sensor.sleepnumber_carlo_carlo_is_in_bed`, `binary_sensor.sleepnumber_carlo_stacey_is_in_bed`, `sensor.sleepnumber_carlo_carlo_sleep_score`, `sensor.sleepnumber_carlo_stacey_sleep_score`, `sensor.sleep_score_14_day_averages`, `number.sleepnumber_carlo_carlo_firmness`, `select.sleepnumber_carlo_foundation_preset_right` | @@ -116,7 +116,7 @@ When a package has a dedicated blog post or video, I link it right inside the YA | [![YAML source: garadget](https://img.shields.io/static/v1?label=YAML&message=garadget&color=lightgrey&logo=github&logoColor=181717)](garadget.yaml) | Garage arrival hub with auto-open guardrails, entry prompts, nighttime reminders, camera context, and the older MQTT/token setup notes. | [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/wFO0H5zgnzk) · [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2026/05/home-assistant-garage-automations-arrival-alerts-tesla.html) · [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2019/03/how-to-add-garadget-to-home-assistant.html) · [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2020/05/fixing-garadget-in-home-assistant.html) | | [![YAML source: garage_entry_light](https://img.shields.io/static/v1?label=YAML&message=garage_entry_light&color=lightgrey&logo=github&logoColor=181717)](../automation/garage_entry_light.yaml) | Simple door-open and door-closed hallway lighting pattern from the garage walkthrough. | [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/wFO0H5zgnzk) · [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2026/05/home-assistant-garage-automations-arrival-alerts-tesla.html) | | [![YAML source: kiosk_tablet](https://img.shields.io/static/v1?label=YAML&message=kiosk_tablet&color=lightgrey&logo=github&logoColor=181717)](kiosk_tablet.yaml) + [Bedroom dashboard](../dashboards/bedroom) + [Kiosk Satellite preview](../dashboards/kiosk_satellite_staging) | Shared event priority with purpose-built display surfaces: clock-free Bedroom night-watch context and cameras through Fully, plus Kitchen clock/forecast, reminders, native camera overlays, motion-aware dimming, and household-aware screen power through Kiosk Satellite. | [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/ChgEu0IDWzc) · [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2017/08/project-update-visualization-of-home.html) | -| [![YAML source: tesla_model_y](https://img.shields.io/static/v1?label=YAML&message=tesla_model_y&color=lightgrey&logo=github&logoColor=181717)](tesla_model_y.yaml) | Tesla Fleet tire-pressure alerts with a shared briefing snooze, garage-close arrival signals for low-battery plug-in reminders, a JuiceBox-only 20 A current alarm, and a three-minute Joanna investigation followed by a five-minute critical alert for missing JuiceBox MQTT reports, with automatic clearing when reports resume. | [![Watch tire-pressure video](https://img.shields.io/badge/Watch-Tire%20Pressure-FF0000?logo=youtube&logoColor=white)](https://youtu.be/hDNyZfTfTtE) · [![Tire-pressure blog post](https://img.shields.io/static/v1?label=vCloudInfo&message=Tire%20Pressure&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2026/09/home-assistant-tesla-tire-pressure-alerts.html) · [![Watch garage video](https://img.shields.io/badge/Watch-Garage%20Automations-FF0000?logo=youtube&logoColor=white)](https://youtu.be/wFO0H5zgnzk) · [![Garage automations blog post](https://img.shields.io/static/v1?label=vCloudInfo&message=Garage%20Automations&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2026/05/home-assistant-garage-automations-arrival-alerts-tesla.html) | +| [![YAML source: tesla_model_y](https://img.shields.io/static/v1?label=YAML&message=tesla_model_y&color=lightgrey&logo=github&logoColor=181717)](tesla_model_y.yaml) | Tesla tire-pressure alerts and garage-driven plug-in reminders, plus charging-aware protection: fresh home draw above 18 A requests a verified stop; missing reports dispatch investigation at 3 minutes, protect an unverified home session at 5 minutes, and quietly warn about persistent monitoring at 15 minutes. Tagged incident cleanup and a restoring human restart-review latch prevent false recovery and unattended resumption. | [![Watch tire-pressure video](https://img.shields.io/badge/Watch-Tire%20Pressure-FF0000?logo=youtube&logoColor=white)](https://youtu.be/hDNyZfTfTtE) · [![Tire-pressure blog post](https://img.shields.io/static/v1?label=vCloudInfo&message=Tire%20Pressure&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2026/09/home-assistant-tesla-tire-pressure-alerts.html) · [![Watch garage video](https://img.shields.io/badge/Watch-Garage%20Automations-FF0000?logo=youtube&logoColor=white)](https://youtu.be/wFO0H5zgnzk) · [![Garage automations blog post](https://img.shields.io/static/v1?label=vCloudInfo&message=Garage%20Automations&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2026/05/home-assistant-garage-automations-arrival-alerts-tesla.html) | | [![YAML source: llmvision](https://img.shields.io/static/v1?label=YAML&message=llmvision&color=lightgrey&logo=github&logoColor=181717)](llmvision.yaml) | LLM Vision package reminder flow that turns a front-door camera event into a stored package-present state and phone alert. | [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/nAhCezFetvI) | | [![YAML source: maintenance_log](https://img.shields.io/static/v1?label=YAML&message=maintenance_log&color=lightgrey&logo=github&logoColor=181717)](maintenance_log.yaml) + [![YAML source: 09_salt_trends](https://img.shields.io/static/v1?label=YAML&message=09_salt_trends&color=lightgrey&logo=github&logoColor=181717)](../dashboards/infrastructure/views/09_salt_trends.yaml) + [![YAML source: home_water_sections](https://img.shields.io/static/v1?label=YAML&message=home_water_sections&color=lightgrey&logo=github&logoColor=181717)](../dashboards/infrastructure/partials/home_water_sections.yaml) | Water softener salt maintenance flow with a reusable webhook, duplicate protection, helper-backed history, template sensors, and Salt Trends dashboard views. | [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/_OqTv9i-jAs) · [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2026/07/home-assistant-water-softener-salt-automation.html) · [Issue #1816](https://github.com/CCOSTAN/Home-AssistantConfig/issues/1816) | | [![YAML source: climate](https://img.shields.io/static/v1?label=YAML&message=climate&color=lightgrey&logo=github&logoColor=181717)](climate.yaml) + [![YAML source: home_water_sections](https://img.shields.io/static/v1?label=YAML&message=home_water_sections&color=lightgrey&logo=github&logoColor=181717)](../dashboards/infrastructure/partials/home_water_sections.yaml) | HVAC blower-failure evidence, runtime-based AC filter reminders, two-unit native NFC tag change capture, per-unit change counts/cadence, reset actions, and 360-day condenser-line cleanout reminders. | [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/nScl2JxYSB8) · [Issue #1817](https://github.com/CCOSTAN/Home-AssistantConfig/issues/1817) · [Issue #273](https://github.com/CCOSTAN/Home-AssistantConfig/issues/273) | diff --git a/config/packages/infrastructure.yaml b/config/packages/infrastructure.yaml index 9075a6b1..fcef1ccb 100644 --- a/config/packages/infrastructure.yaml +++ b/config/packages/infrastructure.yaml @@ -246,7 +246,7 @@ template: 'binary_sensor.infra_compute_degraded', 'binary_sensor.infra_storage_degraded', 'binary_sensor.infra_external_monitoring_degraded', - 'binary_sensor.juicepassproxy_monitoring_degraded', + 'binary_sensor.tesla_home_charge_attention', 'binary_sensor.infra_ups_on_battery', 'binary_sensor.docker_host_maintenance_attention', 'binary_sensor.hvac_maintenance_due', @@ -264,7 +264,7 @@ template: 'binary_sensor.infra_compute_degraded', 'binary_sensor.infra_storage_degraded', 'binary_sensor.infra_external_monitoring_degraded', - 'binary_sensor.juicepassproxy_monitoring_degraded', + 'binary_sensor.tesla_home_charge_attention', 'binary_sensor.infra_ups_on_battery', 'binary_sensor.docker_host_maintenance_attention', 'binary_sensor.hvac_maintenance_due', diff --git a/config/packages/tesla_model_y.yaml b/config/packages/tesla_model_y.yaml index e595909c..4133089e 100644 --- a/config/packages/tesla_model_y.yaml +++ b/config/packages/tesla_model_y.yaml @@ -28,16 +28,21 @@ # live Tesla Fleet and JuiceBox reports remain the normal update path. # Notes: Snapshot sensors intentionally omit state_class; TeslaMate remains the # long-term vehicle source while raw JuiceBox energy owns charge statistics. -# Notes: Home charging is intentionally limited to 16 A in the Tesla; this -# package observes JuiceBox current but never changes the vehicle or EVSE limit. +# Notes: Home charging is intentionally limited to 16 A in the Tesla. Fresh +# actual draw above 18 A requests a native Tesla stop; current limits are unchanged. # Notes: The 30 A home EVSE circuit's EEPROM-backed offline wire rating was set # to 24 A during unplugged commissioning. JuicePassProxy is a passive sensor-only # monitor that sends no charger or former-cloud UDP traffic and exposes no # limit controls. Transient online/offline max-current fields are ignored. -# Notes: Persisted JuiceBox current reaching 20 A sends one critical parents -# push plus an exact garage announcement; a restoring latch prevents repeats. -# Notes: Missing proxy MQTT reports dispatch Joanna after three minutes and -# alert the household after five; fresh reports clear the Systems problem. +# Notes: Source verification ignores retained MQTT and cached Tesla timestamps. +# Tesla samples expire after 120 seconds; live JuiceBox reports after 60 seconds. +# Notes: Missing reports dispatch Joanna at three minutes. At five minutes, +# unverified home sessions request a protective stop; verified safe/stopped +# sessions get a quiet monitoring warning only if the outage persists 15 minutes. +# Notes: Stops require home/AC context and fresh post-command zero-current proof. +# A restoring review latch blocks home charging resumption until manually cleared. +# No automatic start commands are issued. Tagged incident pushes clear only after +# their own recovery checks. Older untagged alerts require dismissal on the phone. # Notes: Portainer state remains diagnostic and cannot by itself mark the # charger monitor offline. # Tire-pressure video: https://youtu.be/hDNyZfTfTtE @@ -48,8 +53,19 @@ input_boolean: tesla_high_charge_rate_alert_active: - name: Tesla High Charge Rate Alert Active + name: Tesla Home Charging Incident Active icon: mdi:car-electric-alert + tesla_home_charge_restart_required: + name: Tesla Home Charging Restart Review Required + icon: mdi:car-electric-outline + tesla_home_charge_session_active: + name: Tesla Home Charging Session Active + icon: mdi:ev-plug-type1 + +tesla_charge_guard: + entity_id: sensor.spaceship_charger_current + mqtt_prefix: hmd/sensor/JuiceBox + current_limit: 18 mqtt: sensor: @@ -91,7 +107,50 @@ template: mqtt_heartbeat: "{{ states('sensor.juicepassproxy_mqtt_heartbeat') }}" source_state: "{{ states('sensor.juicepassproxy_state') }}" source_health: "{{ states('sensor.juicepassproxy_health') }}" - guidance: "Verify charger monitoring before relying on the high-current alert." + guidance: "{{ state_attr('sensor.tesla_home_charge_guard', 'reason') }}" + + - name: "JuicePassProxy Monitoring Persistent" + unique_id: juicepassproxy_monitoring_persistent + device_class: problem + delay_on: "00:14:00" + state: "{{ not is_state('sensor.juicepassproxy_mqtt_heartbeat', 'online') }}" + + - name: "Tesla Home Charge Critical" + unique_id: tesla_home_charge_critical + device_class: problem + state: >- + {{ is_state('input_boolean.tesla_high_charge_rate_alert_active', 'on') + or (is_state('binary_sensor.juicepassproxy_monitoring_degraded', 'on') + and is_state('sensor.tesla_home_charge_guard', 'unverified') + and state_attr('sensor.tesla_home_charge_guard', 'home_session') == true) }} + + - name: "Tesla Home Charge Attention" + unique_id: tesla_home_charge_attention + device_class: problem + state: >- + {{ is_state('binary_sensor.juicepassproxy_monitoring_degraded', 'on') + or is_state('binary_sensor.tesla_home_charge_critical', 'on') + or is_state('input_boolean.tesla_home_charge_restart_required', 'on') }} + + - triggers: + - trigger: event + event_type: tesla_charge_guard_report + sensor: + - name: "Tesla Home Charge Guard" + unique_id: tesla_home_charge_guard + icon: mdi:shield-car + state: "{{ trigger.event.data.status }}" + attributes: + actual_current: "{{ trigger.event.data.actual_current }}" + source: "{{ trigger.event.data.source }}" + sample_time: "{{ trigger.event.data.sample_time }}" + checked_at: "{{ trigger.event.data.checked_at }}" + tesla_fresh: "{{ trigger.event.data.tesla_fresh }}" + juicebox_fresh: "{{ trigger.event.data.juicebox_fresh }}" + home_session: "{{ trigger.event.data.home_session }}" + session_observed: "{{ trigger.event.data.session_observed }}" + stop_allowed: "{{ trigger.event.data.stop_allowed }}" + reason: "{{ trigger.event.data.reason }}" - triggers: - trigger: state @@ -277,89 +336,277 @@ template: - "{{ snapshot_source_entity == 'sensor.juicebox_power_factor' }}" state: "{{ snapshot_state }}" -automation: - - alias: "Tesla Model Y - High Home Charge Rate Alert" - id: f4f013f9-74e7-492e-8400-ae84897086ef +script: + tesla_home_charge_dispatch: + alias: Tesla Home Charge - Joanna Investigation + mode: queued + max: 3 + fields: + summary: + description: Verified incident summary + request: + description: Investigation scope + sequence: + - service: script.joanna_dispatch + data: + trigger_context: "HA Tesla home charging protection" + source: "home_assistant_automation.juicepassproxy_monitoring_alert" + summary: "{{ summary }}" + entity_ids: + - sensor.tesla_home_charge_guard + - sensor.juicepassproxy_mqtt_heartbeat + - binary_sensor.tesla_home_charge_critical + - sensor.juicepassproxy_state + - sensor.juicepassproxy_health + - sensor.spaceship_charger_current + - sensor.spaceship_charging + - switch.spaceship_charge + - input_boolean.tesla_home_charge_restart_required + diagnostics: >- + assessment={{ states('sensor.tesla_home_charge_guard') }}, + actual_current={{ state_attr('sensor.tesla_home_charge_guard', 'actual_current') }}, + source={{ state_attr('sensor.tesla_home_charge_guard', 'source') }}, + sample_time={{ state_attr('sensor.tesla_home_charge_guard', 'sample_time') }}, + home_session={{ state_attr('sensor.tesla_home_charge_guard', 'home_session') }}, + mqtt_heartbeat={{ states('sensor.juicepassproxy_mqtt_heartbeat') }} + request: "{{ request }}" + + tesla_home_charge_protect: + alias: Tesla Home Charge - Protective Stop mode: single max_exceeded: silent - trigger: - - platform: numeric_state - entity_id: sensor.juicebox_current_last_known - above: 19.99 - id: juicebox_current - - platform: homeassistant - event: start - id: startup - condition: - - condition: state - entity_id: input_boolean.tesla_high_charge_rate_alert_active - state: 'off' - - condition: numeric_state - entity_id: sensor.juicebox_current_last_known - above: 19.99 - action: + fields: + reason: + description: Why home charging needs to be paused + sequence: + - service: tesla_charge_guard.snapshot + data: + refresh: true + response_variable: guard + - condition: template + value_template: >- + {{ guard.status == 'excessive' + or (guard.status == 'unverified' and guard.home_session) + or (guard.status == 'charging_safe' + and is_state('input_boolean.tesla_home_charge_restart_required', 'on')) }} + - variables: + stop_requested_at: "{{ as_timestamp(now()) }}" + incident_reason: "{{ reason | default(guard.reason, true) }}" - service: input_boolean.turn_on target: - entity_id: input_boolean.tesla_high_charge_rate_alert_active - - variables: - observed_current: >- - {{ states('sensor.juicebox_current_last_known') | float(0) | round(1) }} + entity_id: + - input_boolean.tesla_high_charge_rate_alert_active + - input_boolean.tesla_home_charge_restart_required - parallel: + - sequence: + - condition: template + value_template: "{{ guard.stop_allowed }}" + - service: switch.turn_off + continue_on_error: true + target: + entity_id: switch.spaceship_charge + - service: script.notify_engine + continue_on_error: true + data: + title: "URGENT: Tesla Home Charging" + value1: >- + {{ incident_reason }} + {{ 'A Tesla stop was requested; verification is pending.' if guard.stop_allowed + else 'A vehicle stop is blocked because home AC charging cannot be established. Check the charger.' }} + who: parents + group: Tesla_Charge_Rate_Alarm + tag: Tesla_Charge_Rate_Alarm + level: critical - service: notify.alexa_media_garage continue_on_error: true data: message: >- - Warning. The JuiceBox reports the Tesla charging at - {{ observed_current }} amps, at or above the twenty amp safety - threshold. Unplug the Tesla now. + Warning. {{ incident_reason }} + {{ 'A charging stop was requested. Check the Tesla if charging continues.' if guard.stop_allowed + else 'Check the charger. The vehicle location could not be verified for a stop.' }} data: type: announce + - service: script.tesla_home_charge_dispatch + continue_on_error: true + data: + summary: "{{ incident_reason }}" + request: >- + Home Assistant owns the protective stop and restart-review latch. + Verify fresh actual-current reports and the owning monitoring path. + Repair monitoring when safe; check for an active deployment before + restarting a container. Do not change current limits, clear the review + latch, or start charging. Report verified results and any stop failure. + - repeat: + count: 2 + sequence: + - if: + - condition: template + value_template: >- + {{ not (is_state('sensor.tesla_home_charge_guard', 'stopped') + and state_attr('sensor.tesla_home_charge_guard', 'sample_time') | float(0) > stop_requested_at | float(0)) }} + then: + - delay: "00:00:05" + - service: tesla_charge_guard.snapshot + continue_on_error: true + data: + refresh: true + response_variable: verification + - wait_template: >- + {{ is_state('sensor.tesla_home_charge_guard', 'stopped') + and state_attr('sensor.tesla_home_charge_guard', 'sample_time') | float(0) > stop_requested_at | float(0) }} + timeout: "00:00:25" + continue_on_timeout: true + - if: + - condition: template + value_template: >- + {{ not wait.completed and repeat.index == 1 + and state_attr('sensor.tesla_home_charge_guard', 'stop_allowed') == true }} + then: + - service: switch.turn_off + continue_on_error: true + target: + entity_id: switch.spaceship_charge + - variables: + stop_verified: >- + {{ is_state('sensor.tesla_home_charge_guard', 'stopped') + and state_attr('sensor.tesla_home_charge_guard', 'sample_time') | float(0) > stop_requested_at | float(0) }} + - if: + - condition: template + value_template: "{{ not stop_verified }}" + then: - service: script.notify_engine continue_on_error: true data: - title: "URGENT: Unplug Tesla" + title: "URGENT: Tesla Stop Unverified" value1: >- - The JuiceBox reports {{ observed_current }} A, at or above the - 20 A safety threshold. Unplug the Tesla now. - who: 'parents' - group: 'Tesla_Charge_Rate_Alarm' - level: 'critical' + {{ incident_reason }} Fresh zero-current confirmation did not arrive. + Check the Tesla and unplug it if charging continues. Home charging + requires your review before resuming. + who: parents + group: Tesla_Charge_Rate_Alarm + tag: Tesla_Charge_Rate_Alarm + level: critical - service: script.send_to_logbook data: - topic: "JUICEBOX" + topic: JUICEBOX message: >- - High charge-rate alarm sent at {{ observed_current }} A; garage and - parents were told to unplug the Tesla. + {{ incident_reason }} Stop {{ 'verified by a fresh zero-current report' if stop_verified else 'unverified after bounded attempts' }}. + Home charging restart review remains required; no start command was issued. - - alias: "Tesla Model Y - Reset High Charge Rate Alert" - id: ccb67406-4ac1-41de-8f47-a3a76e6d8762 +automation: + - alias: "Tesla Model Y - Refresh Home Charge Verification" + id: 7602df3d-20b6-448d-bbcd-b00a774e6dd9 mode: single + max_exceeded: silent trigger: - - platform: state - entity_id: sensor.juicebox_current_last_known - platform: homeassistant event: start + id: startup + - platform: time_pattern + minutes: "/1" + id: periodic + - platform: state + entity_id: + - sensor.spaceship_charger_current + - sensor.spaceship_charging + - device_tracker.spaceship_location + - input_boolean.tesla_home_charge_restart_required + id: vehicle + action: + - service: tesla_charge_guard.snapshot + continue_on_error: true + data: + refresh: >- + {{ trigger.id == 'periodic' + and (is_state('binary_sensor.juicepassproxy_investigation_needed', 'on') + or (is_state('sensor.tesla_home_charge_guard', 'unverified') + and state_attr('sensor.tesla_home_charge_guard', 'juicebox_fresh') == true)) }} + + - alias: "Tesla Model Y - High Home Charge Rate Alert" + id: f4f013f9-74e7-492e-8400-ae84897086ef + mode: queued + max: 5 + max_exceeded: silent + trigger: + - platform: event + event_type: tesla_charge_guard_report + action: + - if: + - condition: template + value_template: >- + {{ trigger.event.data.session_observed is not none + and is_state('input_boolean.tesla_home_charge_session_active', 'on') != trigger.event.data.session_observed }} + then: + - service: >- + {{ 'input_boolean.turn_on' if trigger.event.data.session_observed else 'input_boolean.turn_off' }} + target: + entity_id: input_boolean.tesla_home_charge_session_active + - if: + - condition: state + entity_id: input_boolean.tesla_high_charge_rate_alert_active + state: 'off' + - condition: template + value_template: >- + {{ trigger.event.data.status == 'excessive' + or (trigger.event.data.status == 'charging_safe' + and is_state('input_boolean.tesla_home_charge_restart_required', 'on')) }} + then: + - service: script.turn_on + target: + entity_id: script.tesla_home_charge_protect + data: + variables: + reason: >- + {{ 'Home charging resumed while restart review was still required.' if trigger.event.data.status == 'charging_safe' + else 'Fresh home charging draw is ' ~ trigger.event.data.actual_current ~ ' A, above the 18 A guardrail.' }} + + - alias: "Tesla Model Y - Reset High Charge Rate Alert" + id: ccb67406-4ac1-41de-8f47-a3a76e6d8762 + mode: queued + max: 3 + trigger: + - platform: state + entity_id: sensor.tesla_home_charge_guard + to: stopped + - platform: state + entity_id: script.tesla_home_charge_protect + to: 'off' condition: - condition: state entity_id: input_boolean.tesla_high_charge_rate_alert_active state: 'on' - - condition: numeric_state - entity_id: sensor.juicebox_current_last_known - below: 20 + - condition: state + entity_id: script.tesla_home_charge_protect + state: 'off' + - condition: state + entity_id: sensor.tesla_home_charge_guard + state: stopped + - condition: template + value_template: >- + {% set sample = state_attr('sensor.tesla_home_charge_guard', 'sample_time') | float(0) %} + {{ sample > as_timestamp(states.input_boolean.tesla_high_charge_rate_alert_active.last_changed) + and -5 <= as_timestamp(now()) - sample <= 60 }} action: - service: input_boolean.turn_off target: entity_id: input_boolean.tesla_high_charge_rate_alert_active + - service: notify.ios_parents + continue_on_error: true + data: + message: clear_notification + data: + tag: Tesla_Charge_Rate_Alarm - service: script.send_to_logbook data: - topic: "JUICEBOX" + topic: JUICEBOX message: >- - High charge-rate alarm reset; JuiceBox current is below 20 A. + Fresh post-incident zero current verified; the critical charging incident + cleared. Restart review stays enabled until a person clears it. - alias: "Tesla Model Y - JuicePassProxy Monitoring Alert" id: e4b6bf07-4760-4e7c-b2da-6db234a46df8 - mode: parallel - max: 3 + mode: queued + max: 5 trigger: - platform: state entity_id: binary_sensor.juicepassproxy_investigation_needed @@ -368,100 +615,122 @@ automation: - platform: state entity_id: binary_sensor.juicepassproxy_monitoring_degraded to: 'on' - id: critical + id: assess - platform: state - entity_id: binary_sensor.juicepassproxy_monitoring_degraded - from: 'on' - to: 'off' + entity_id: binary_sensor.tesla_home_charge_critical + to: 'on' + id: risk + - platform: state + entity_id: binary_sensor.juicepassproxy_monitoring_persistent + to: 'on' + id: persistent + - platform: state + entity_id: sensor.juicepassproxy_mqtt_heartbeat + to: online + id: restored + - platform: state + entity_id: sensor.tesla_home_charge_guard + attribute: juicebox_fresh + to: true id: restored action: - choose: - conditions: - condition: trigger id: investigate + - condition: state + entity_id: binary_sensor.juicepassproxy_investigation_needed + state: 'on' sequence: - - service: script.joanna_dispatch + - service: tesla_charge_guard.snapshot + continue_on_error: true + data: + refresh: true + - service: script.tesla_home_charge_dispatch continue_on_error: true data: - trigger_context: "HA automation e4b6bf07-4760-4e7c-b2da-6db234a46df8 (JuicePassProxy Monitoring Alert)" - source: "home_assistant_automation.juicepassproxy_monitoring_alert" summary: "JuiceBox MQTT reports have been absent for three minutes" - entity_ids: - - sensor.juicepassproxy_mqtt_heartbeat - - binary_sensor.juicepassproxy_investigation_needed - - binary_sensor.juicepassproxy_monitoring_degraded - - sensor.juicepassproxy_state - - sensor.juicepassproxy_health - - sensor.juicebox_status - - sensor.juicebox_current - - sensor.spaceship_charging - diagnostics: >- - mqtt_heartbeat={{ states('sensor.juicepassproxy_mqtt_heartbeat') }}, - proxy_state={{ states('sensor.juicepassproxy_state') }}, - proxy_health={{ states('sensor.juicepassproxy_health') }}, - juicebox_status={{ states('sensor.juicebox_status') }}, - juicebox_current={{ states('sensor.juicebox_current') }} A, - tesla_charging={{ states('sensor.spaceship_charging') }} request: >- - Verify the owning JuicePassProxy container and recent MQTT reports. - Restore the failed monitoring path when safe; check for an active - deployment before restarting a container. Do not alter charger - limits or interrupt vehicle charging. Report verified recovery. + Verify the owning JuicePassProxy container and recent live MQTT + reports. Restore the failed path when safe; check for an active + deployment before restarting a container. Home Assistant owns + charging protection. Do not change limits, start charging, or + clear the restart-review latch. Report verified recovery. - service: script.send_to_logbook data: - topic: "JUICEBOX" - message: >- - JuiceBox MQTT reports were missing for three minutes; Joanna - investigation was requested before alerting the household. + topic: JUICEBOX + message: "Missing reports for three minutes; Joanna dispatched with source verification." - conditions: - condition: trigger - id: critical + id: + - assess + - risk + - condition: state + entity_id: binary_sensor.juicepassproxy_monitoring_degraded + state: 'on' + - condition: state + entity_id: input_boolean.tesla_high_charge_rate_alert_active + state: 'off' sequence: - - parallel: - - service: notify.alexa_media_garage - continue_on_error: true + - service: tesla_charge_guard.snapshot + data: + refresh: true + response_variable: guard + - if: + - condition: template + value_template: "{{ guard.status == 'unverified' and guard.home_session }}" + then: + - service: script.tesla_home_charge_protect data: - message: >- - Warning. JuiceBox monitoring reports are still missing - after five minutes. Check the Tesla and charger before - relying on the high-current alert. - data: - type: announce - - service: script.notify_engine - continue_on_error: true + reason: "Both charging sources are unverified during a home charging session after five minutes without JuiceBox reports." + else: + - service: script.send_to_logbook data: - title: "URGENT: Check JuiceBox Monitoring" - value1: >- - JuiceBox MQTT reports are still missing after five - minutes. Joanna was dispatched to investigate. Check the - Tesla and charger; do not rely on the high-current alert - until monitoring returns. - who: 'parents' - group: 'JuiceBox_Proxy_Offline' - level: 'critical' - - service: script.send_to_logbook - data: - topic: "JUICEBOX" - message: >- - JuiceBox MQTT reports were still missing after five minutes; - the garage and parents were alerted after Joanna dispatch. + topic: JUICEBOX + message: >- + Five-minute monitoring assessment: {{ guard.status }}. + No charging critical alert was sent; persistent monitoring + faults receive a quiet warning at 15 minutes. - conditions: - condition: trigger - id: restored + id: persistent + - condition: state + entity_id: binary_sensor.juicepassproxy_monitoring_persistent + state: 'on' + - condition: state + entity_id: binary_sensor.tesla_home_charge_critical + state: 'off' sequence: - service: script.notify_engine continue_on_error: true data: - title: "JuiceBox Monitor Restored" + title: JuiceBox Monitoring Needs Review value1: >- - Fresh JuiceBox reports have resumed. Charging telemetry and - the 20 A high-current alert are available again. - who: 'parents' - group: 'JuiceBox_Proxy_Offline' + JuiceBox reports have been missing for 15 minutes. Joanna was + dispatched. Charging assessment: {{ states('sensor.tesla_home_charge_guard') }}. + who: parents + group: JuiceBox_Proxy_Offline + tag: JuiceBox_Proxy_Offline + level: passive + - conditions: + - condition: trigger + id: restored + - condition: state + entity_id: sensor.juicepassproxy_mqtt_heartbeat + state: online + - condition: template + value_template: "{{ state_attr('sensor.tesla_home_charge_guard', 'juicebox_fresh') == true }}" + sequence: + - service: notify.ios_parents + continue_on_error: true + data: + message: clear_notification + data: + tag: JuiceBox_Proxy_Offline - service: script.send_to_logbook data: - topic: "JUICEBOX" - message: "Fresh JuiceBox MQTT reports resumed; the monitoring alert cleared." + topic: JUICEBOX + message: "Live JuiceBox reports verified; matching monitoring notification cleared." - alias: "Tesla Model Y - Plug In Reminder" id: 6a873341-c823-4d5d-9d42-fc2df52b93db diff --git a/config/recorder.yaml b/config/recorder.yaml index e7d4cec9..14f1bbaf 100755 --- a/config/recorder.yaml +++ b/config/recorder.yaml @@ -197,6 +197,8 @@ exclude: entities: - automation.update_garage_get_status + # Source verification is live-only; critical/attention sensors and curated incidents remain recorded. + - sensor.tesla_home_charge_guard - automation.docker_telemetry_template_refresh - sensor.bgw_probe_payload - sensor.infra_dashboard_active_issue_count @@ -290,3 +292,4 @@ exclude: event_types: - alexa_smart_home + - tesla_charge_guard_report diff --git a/config/script/README.md b/config/script/README.md index b47e1937..45f095d1 100755 --- a/config/script/README.md +++ b/config/script/README.md @@ -27,7 +27,7 @@ Reusable scripts that other automations call for notifications, lighting, safety ### Featured files to browse | File | Why it matters | | --- | --- | -| [![YAML source: notify_engine](https://img.shields.io/static/v1?label=YAML&message=notify_engine&color=lightgrey&logo=github&logoColor=181717)](notify_engine.yaml) | Single entrypoint for rich push notifications. | +| [![YAML source: notify_engine](https://img.shields.io/static/v1?label=YAML&message=notify_engine&color=lightgrey&logo=github&logoColor=181717)](notify_engine.yaml) | Single entrypoint for rich push notifications, with optional tags that let recovery actions target a specific notification for clearing. | | [![YAML source: hvac_control](https://img.shields.io/static/v1?label=YAML&message=hvac_control&color=lightgrey&logo=github&logoColor=181717)](hvac_control.yaml) | Single reconciler for grid, entry-point, occupancy, and target-temperature HVAC policy. | | [![YAML source: notify_live_activity](https://img.shields.io/static/v1?label=YAML&message=notify_live_activity&color=lightgrey&logo=github&logoColor=181717)](notify_live_activity.yaml) | Shared helper for tagged live activity/live update pushes and clear commands. | | [![YAML source: kiosk_driveway_camera_takeover](https://img.shields.io/static/v1?label=YAML&message=kiosk_driveway_camera_takeover&color=lightgrey&logo=github&logoColor=181717)](kiosk_driveway_camera_takeover.yaml) | Coordinates the bounded Driveway camera takeover and wake behavior across both household kiosks. | diff --git a/config/script/notify_engine.yaml b/config/script/notify_engine.yaml index 30d9e8da..c87f67c5 100755 --- a/config/script/notify_engine.yaml +++ b/config/script/notify_engine.yaml @@ -6,6 +6,8 @@ # Notification Engine Scripts - Flexible push helpers for family devices. # Compose messages from value1/value2/value3 with optional actions. # ------------------------------------------------------------------- +# Notes: Optional notification tags let recovery actions target a specific push. +# Untagged callers retain their existing payload; groups are only visual. # Video Tutorial: https://youtu.be/mK1wdpxhLbM ###################################################################### ### Script for Sending Notifications to Mobile Phones @@ -24,6 +26,7 @@ # - 'who': Recipient identifier (e.g., 'stacey', 'carlo', 'paige', 'family', 'parents'). # - 'camera_entity': Camera entity ID for snapshots (optional). # - 'group': Notification group (optional, defaults to 'information'). +# - 'tag': Stable notification identity for replacement or clearing (optional). # - 'level': Interruption level for the notification (optional, defaults to 'active'). # The 'notify_engine_two_button' variant includes additional parameters for two interactive buttons: @@ -61,6 +64,14 @@ notify_engine: has_actions: >- {{ action1 | default('', true) | length > 0 or action2 | default('', true) | length > 0 }} + notify_data: >- + {% set payload = { + 'group': group | default('information', true), + 'entity_id': camera_entity | default('', true), + 'push': {'interruption-level': level | default('active', true)} + } %} + {% set notification_tag = tag | default('', true) | string | trim %} + {{ dict(payload, tag=notification_tag) if notification_tag else payload }} - choose: - conditions: "{{ has_actions }}" sequence: @@ -68,30 +79,27 @@ notify_engine: data: title: "{{ title | default('', true) }}" message: "{{ notify_message }}" - data: - actions: - - title: "{{ title1 | default('', true) }}" - action: "{{ action1 | default('', true) }}" - icon: "{{ icon1 | default('sfsymbols:house.circle', true) }}" - destructive: "{{ destructive1 | default(false, true) }}" - - title: "{{ title2 | default('', true) }}" - action: "{{ action2 | default('', true) }}" - icon: "{{ icon2 | default('sfsymbols:house.circle', true) }}" - destructive: "{{ destructive2 | default(false, true) }}" - group: "{{ group | default('information', true) }}" - entity_id: "{{ camera_entity | default('', true) }}" - push: - interruption-level: "{{ level | default('active', true) }}" + data: >- + {{ dict(notify_data, actions=[ + { + 'title': title1 | default('', true), + 'action': action1 | default('', true), + 'icon': icon1 | default('sfsymbols:house.circle', true), + 'destructive': destructive1 | default(false, true) + }, + { + 'title': title2 | default('', true), + 'action': action2 | default('', true), + 'icon': icon2 | default('sfsymbols:house.circle', true), + 'destructive': destructive2 | default(false, true) + } + ]) }} default: - service: "{{ notify_service }}" data: title: "{{ title | default('', true) }}" message: "{{ notify_message }}" - data: - group: "{{ group | default('information', true) }}" - entity_id: "{{ camera_entity | default('', true) }}" - push: - interruption-level: "{{ level | default('active', true) }}" + data: "{{ notify_data }}" notify_engine_two_button: alias: Notify Engine Two Button @@ -106,6 +114,7 @@ notify_engine_two_button: value3: "{{ value3 | default('', true) }}" who: "{{ who | default('family', true) }}" group: "{{ group | default('information', true) }}" + tag: "{{ tag | default('', true) }}" level: "{{ level | default('active', true) }}" camera_entity: "{{ camera_entity | default('', true) }}" title1: "{{ title1 | default('', true) }}" diff --git a/tools/test_tesla_charge_guard.py b/tools/test_tesla_charge_guard.py new file mode 100644 index 00000000..39a10a27 --- /dev/null +++ b/tools/test_tesla_charge_guard.py @@ -0,0 +1,113 @@ +"""Safety policy contracts; no live services, credentials, or vehicle commands.""" + +import importlib.util +from pathlib import Path +import unittest + +spec = importlib.util.spec_from_file_location( + "charge_policy", Path(__file__).resolve().parents[1] + / "config/custom_components/tesla_charge_guard/policy.py") +policy = importlib.util.module_from_spec(spec) +spec.loader.exec_module(policy) + + +class ChargePolicyTests(unittest.TestCase): + def setUp(self): + self.now = 10_000 + self.tesla = dict(current=16, charging="charging", sample_time=self.now, + location_time=self.now, at_home=True, dc=False, plugged=True) + self.juicebox = dict(current=16, status="Charging", current_time=self.now, + status_time=self.now) + + def assess(self): + return policy.classify(self.tesla, self.juicebox, self.now) + + def test_actual_current_guardrail_and_no_masking_by_lower_source(self): + for source in (self.tesla, self.juicebox): + with self.subTest(source=source): + source["current"] = 18.1 + self.assertEqual(self.assess()["status"], "excessive") + self.assertTrue(self.assess()["stop_allowed"]) + source["current"] = 18 + self.assertEqual(self.assess()["status"], "charging_safe") + + def test_cached_zero_and_missing_values_never_prove_stopped(self): + self.tesla.update(current=0, charging="stopped", sample_time=self.now - 121) + self.juicebox.update(current=0, status="Unplugged", current_time=self.now - 61) + self.assertEqual(self.assess()["status"], "unverified") + for invalid in (None, "unknown", "nan", "inf", -1, True): + with self.subTest(invalid=invalid): + self.tesla.update(current=invalid, sample_time=self.now) + self.juicebox.update(current=invalid, current_time=self.now) + self.assertEqual(self.assess()["status"], "unverified") + + def test_fresh_away_and_dc_reports_veto_all_vehicle_stops(self): + self.juicebox["current"] = 24 + self.tesla["at_home"] = False + self.assertFalse(self.assess()["stop_allowed"]) + self.tesla.update(at_home=True, dc=True) + self.assertFalse(self.assess()["stop_allowed"]) + self.tesla["current"] = None + result = policy.classify(self.tesla, self.juicebox, self.now, session_active=True) + self.assertFalse(result["stop_allowed"]) + + def test_home_charger_activity_without_vehicle_scope_requires_investigation(self): + result = policy.classify({}, self.juicebox, self.now) + self.assertEqual(result["status"], "unverified") + self.assertTrue(result["home_session"]) + self.assertFalse(result["stop_allowed"]) + self.assertIsNone(result["session_observed"]) + + def test_home_zero_clears_home_incident_without_stopping_vehicle_away(self): + self.tesla["at_home"] = False + self.juicebox.update(current=0, status="Unplugged") + result = self.assess() + self.assertEqual(result["status"], "stopped") + self.assertFalse(result["stop_allowed"]) + self.assertFalse(result["home_session"]) + + def test_unknown_home_session_keeps_the_protective_stop_scope(self): + self.tesla["sample_time"] = self.now - 121 + self.juicebox["current_time"] = self.now - 61 + result = self.assess() + self.assertEqual(result["status"], "unverified") + self.assertTrue(result["home_session"]) + self.assertTrue(result["stop_allowed"]) + + def test_newer_unplugged_report_does_not_resurrect_old_charging_session(self): + self.tesla["sample_time"] = self.now - 300 + self.juicebox.update(current=0, status="Unplugged", current_time=self.now - 100, + status_time=self.now - 100) + result = self.assess() + self.assertEqual(result["status"], "unverified") + self.assertFalse(result["home_session"]) + self.assertFalse(result["stop_allowed"]) + + def test_restored_home_session_remains_unverified_until_new_source_data(self): + result = policy.classify({}, {}, self.now, session_active=True) + self.assertEqual(result["status"], "unverified") + self.assertTrue(result["stop_allowed"]) + self.assertIsNone(result["session_observed"]) + self.tesla["at_home"] = False + result = policy.classify(self.tesla, {}, self.now, session_active=True) + self.assertFalse(result["stop_allowed"]) + self.assertFalse(result["session_observed"]) + + def test_fresh_zero_requires_a_stopped_state_and_no_active_conflict(self): + self.tesla.update(current=0, charging="stopped") + self.juicebox.update(current=0, status="Unplugged") + self.assertEqual(self.assess()["status"], "stopped") + self.tesla.update(current=16, charging="charging") + self.assertNotEqual(self.assess()["status"], "stopped") + self.tesla.update(current=0, charging="unknown") + self.juicebox["status"] = "unknown" + self.assertEqual(self.assess()["status"], "unverified") + + def test_future_samples_and_old_status_cannot_prove_safe(self): + self.tesla["sample_time"] = self.now + 6 + self.juicebox["status_time"] = self.now - 61 + self.assertEqual(self.assess()["status"], "unverified") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/test_tesla_charge_guard_runtime.py b/tools/test_tesla_charge_guard_runtime.py new file mode 100644 index 00000000..8352fca2 --- /dev/null +++ b/tools/test_tesla_charge_guard_runtime.py @@ -0,0 +1,66 @@ +"""Adapter contracts, run inside an HA environment with upstream I/O isolated.""" + +import asyncio +from pathlib import Path +import sys +from tempfile import TemporaryDirectory +from time import time +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "config")) +from custom_components.tesla_charge_guard import async_setup +from homeassistant.core import HomeAssistant + + +class AdapterContractTests(unittest.IsolatedAsyncioTestCase): + async def test_retained_reports_and_successful_cached_refresh_do_not_prove_safety(self): + with TemporaryDirectory() as directory: + hass = HomeAssistant(directory) + hass.states.async_set("zone.home", "0", {"latitude": 0, "longitude": 0, "radius": 100}) + old = (time() - 600) * 1000 + data = dict(charge_state_charger_actual_current=0, + charge_state_charging_state="Disconnected", + charge_state_timestamp=old, drive_state_timestamp=old, + drive_state_latitude=0, drive_state_longitude=0, + charge_state_fast_charger_present=False) + + async def refresh(): + # A successful refresh with a sleeping vehicle returns cached data. + pass + + vehicle = SimpleNamespace(vin="fixture", coordinator=SimpleNamespace(data=data, async_refresh=refresh)) + registry_entry = SimpleNamespace(platform="tesla_fleet", config_entry_id="fixture", unique_id="fixture-current") + registry = SimpleNamespace(async_get=lambda entity_id: registry_entry) + hass.config_entries = SimpleNamespace(async_get_entry=lambda entry_id: SimpleNamespace(runtime_data=SimpleNamespace(vehicles=[vehicle]))) + callbacks = {} + + async def subscribe(_hass, topic, callback): + callbacks[topic] = callback + return lambda: None + + with patch("custom_components.tesla_charge_guard.er.async_get", return_value=registry), patch("custom_components.tesla_charge_guard.mqtt.async_subscribe", side_effect=subscribe): + await async_setup(hass, {"tesla_charge_guard": dict(entity_id="sensor.fixture", mqtt_prefix="fixture", current_limit=18)}) + callbacks["fixture/Current/state"](SimpleNamespace(topic="fixture/Current/state", payload="24", retain=True)) + callbacks["fixture/Status/state"](SimpleNamespace(topic="fixture/Status/state", payload="Charging", retain=True)) + result = await hass.services.async_call("tesla_charge_guard", "snapshot", {"refresh": True}, blocking=True, return_response=True) + self.assertEqual(result["status"], "unverified") + self.assertFalse(result["tesla_fresh"]) + self.assertFalse(result["juicebox_fresh"]) + callbacks["fixture/Current/state"](SimpleNamespace(topic="fixture/Current/state", payload="24", retain=False)) + callbacks["fixture/Status/state"](SimpleNamespace(topic="fixture/Status/state", payload="Charging", retain=False)) + result = await hass.services.async_call("tesla_charge_guard", "snapshot", {}, blocking=True, return_response=True) + self.assertEqual(result["status"], "excessive") + self.assertEqual(result["actual_current"], 24) + self.assertTrue(result["stop_allowed"]) + self.assertFalse({"vin", "latitude", "longitude", "access_token"} & result.keys()) + callbacks["fixture/Current/state"](SimpleNamespace(topic="fixture/Current/state", payload="nan", retain=False)) + result = await hass.services.async_call("tesla_charge_guard", "snapshot", {}, blocking=True, return_response=True) + self.assertEqual(result["status"], "unverified") + self.assertFalse(result["juicebox_fresh"]) + await hass.async_stop(force=True) + + +if __name__ == "__main__": + unittest.main()