Skip to content

Commit 16385d4

Browse files
xchillxxclaude
andcommitted
Fallback sources for car SoC, plug state and location; unknown plug = not present (v3.0.3)
Fast MQTT car sensors (e.g. TeslaMate) stay unavailable after a Home Assistant restart until the car reports again. The car then had no SoC (no departure obligation could be planned) and an unknown plug state counted as 'maybe present'. Each of these inputs can now have a second source, the plug state also understands text charge-status sensors ('disconnected', 'complete', ...), and an unknown plug state with a plug sensor configured means not present, so no (possibly billed) command goes to an absent car. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cef511d commit 16385d4

9 files changed

Lines changed: 84 additions & 3 deletions

File tree

‎custom_components/surplus_pilot/config_flow.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,13 @@
3131
CONF_CAR_PAUSE_ENTITY,
3232
CONF_CAR_PAUSE_STATE,
3333
CONF_CAR_PHASES,
34+
CONF_CAR_PLUGGED_FALLBACK,
3435
CONF_CAR_PLUGGED_SENSOR,
3536
CONF_CAR_POWER_SENSOR,
37+
CONF_CAR_SOC_FALLBACK,
3638
CONF_CAR_SOC_SENSOR,
3739
CONF_CAR_TRACKER,
40+
CONF_CAR_TRACKER_FALLBACK,
3841
CONF_CAR_VOLTAGE,
3942
CONF_DEV_CLIMATE_MODE,
4043
CONF_DEV_DEPENDS_ON,
@@ -126,13 +129,16 @@ def car_schema(d: dict) -> vol.Schema:
126129
vol.Required(CONF_CAR_CHARGE_SWITCH, **_d(d, CONF_CAR_CHARGE_SWITCH)): _ent("switch"),
127130
vol.Optional(CONF_CAR_CURRENT_ENTITY, **_d(d, CONF_CAR_CURRENT_ENTITY)): _ent("number"),
128131
vol.Optional(CONF_CAR_SOC_SENSOR, **_d(d, CONF_CAR_SOC_SENSOR)): _ent("sensor"),
132+
vol.Optional(CONF_CAR_SOC_FALLBACK, **_d(d, CONF_CAR_SOC_FALLBACK)): _ent("sensor"),
129133
vol.Optional(CONF_CAR_LIMIT_ENTITY, **_d(d, CONF_CAR_LIMIT_ENTITY)): _ent(["number", "sensor"]),
130134
vol.Required(CONF_CAR_LIMIT_DEFAULT, default=d.get(CONF_CAR_LIMIT_DEFAULT, DEFAULT_CAR_LIMIT)):
131135
_num(10, 100, 1, "%"),
132136
vol.Optional(CONF_CAR_PLUGGED_SENSOR, **_d(d, CONF_CAR_PLUGGED_SENSOR)): _ent(["binary_sensor", "sensor"]),
137+
vol.Optional(CONF_CAR_PLUGGED_FALLBACK, **_d(d, CONF_CAR_PLUGGED_FALLBACK)): _ent(["binary_sensor", "sensor"]),
133138
vol.Optional(CONF_CAR_CHARGING_SENSOR, **_d(d, CONF_CAR_CHARGING_SENSOR)): _ent(["binary_sensor", "sensor"]),
134139
vol.Optional(CONF_CAR_POWER_SENSOR, **_d(d, CONF_CAR_POWER_SENSOR)): _ent("sensor"),
135140
vol.Optional(CONF_CAR_TRACKER, **_d(d, CONF_CAR_TRACKER)): _ent(["device_tracker", "person"]),
141+
vol.Optional(CONF_CAR_TRACKER_FALLBACK, **_d(d, CONF_CAR_TRACKER_FALLBACK)): _ent(["device_tracker", "person"]),
136142
vol.Required(CONF_CAR_CAPACITY_KWH, default=d.get(CONF_CAR_CAPACITY_KWH, DEFAULT_CAR_CAPACITY_KWH)):
137143
_num(5, 250, 0.1, "kWh"),
138144
vol.Required(CONF_CAR_EFFICIENCY, default=d.get(CONF_CAR_EFFICIENCY, DEFAULT_CAR_EFFICIENCY)):

‎custom_components/surplus_pilot/const.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,15 @@
3838
CONF_CAR_CHARGE_SWITCH = "charge_switch"
3939
CONF_CAR_CURRENT_ENTITY = "current_entity"
4040
CONF_CAR_SOC_SENSOR = "soc_sensor"
41+
CONF_CAR_SOC_FALLBACK = "soc_fallback_sensor"
4142
CONF_CAR_LIMIT_ENTITY = "limit_entity"
4243
CONF_CAR_LIMIT_DEFAULT = "limit_default"
4344
CONF_CAR_PLUGGED_SENSOR = "plugged_sensor"
45+
CONF_CAR_PLUGGED_FALLBACK = "plugged_fallback_sensor"
4446
CONF_CAR_CHARGING_SENSOR = "charging_sensor"
4547
CONF_CAR_POWER_SENSOR = "power_sensor"
4648
CONF_CAR_TRACKER = "tracker"
49+
CONF_CAR_TRACKER_FALLBACK = "tracker_fallback"
4750
CONF_CAR_CAPACITY_KWH = "capacity_kwh"
4851
CONF_CAR_EFFICIENCY = "efficiency"
4952
CONF_CAR_MIN_A = "min_amps"

‎custom_components/surplus_pilot/coordinator.py‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,13 @@
3434
CONF_CAR_LIMIT_ENTITY,
3535
CONF_CAR_PAUSE_ENTITY,
3636
CONF_CAR_PAUSE_STATE,
37+
CONF_CAR_PLUGGED_FALLBACK,
3738
CONF_CAR_PLUGGED_SENSOR,
3839
CONF_CAR_POWER_SENSOR,
40+
CONF_CAR_SOC_FALLBACK,
3941
CONF_CAR_SOC_SENSOR,
4042
CONF_CAR_TRACKER,
43+
CONF_CAR_TRACKER_FALLBACK,
4144
CONF_DEV_CLIMATE_MODE,
4245
CONF_DEV_DEPENDS_ON,
4346
CONF_DEV_ENTITY,
@@ -89,6 +92,7 @@
8992
async_tibber_prices,
9093
is_on,
9194
number,
95+
plugged as plugged_state,
9296
power_kw,
9397
sensor_prices,
9498
tracker_home,
@@ -369,10 +373,22 @@ async def _async_update_data(self) -> dict:
369373
charging = bool(ch) if ch is not None else (car_kw > 0.3 or self.car.switch_on())
370374
if c.get(CONF_CAR_POWER_SENSOR) is None and charging and c.get(CONF_CAR_CURRENT_ENTITY):
371375
car_kw = (number(self.hass, c[CONF_CAR_CURRENT_ENTITY]) or 0) * self.car.kw_per_a
372-
plugged = is_on(self.hass, c.get(CONF_CAR_PLUGGED_SENSOR))
376+
plugged = plugged_state(self.hass, c.get(CONF_CAR_PLUGGED_SENSOR))
377+
if plugged is None:
378+
plugged = plugged_state(self.hass, c.get(CONF_CAR_PLUGGED_FALLBACK))
373379
home = tracker_home(self.hass, c.get(CONF_CAR_TRACKER))
374-
present = (plugged is not False) and (home is not False)
380+
if home is None:
381+
home = tracker_home(self.hass, c.get(CONF_CAR_TRACKER_FALLBACK))
382+
has_plug_sensor = bool(c.get(CONF_CAR_PLUGGED_SENSOR) or c.get(CONF_CAR_PLUGGED_FALLBACK))
383+
# unknown plug state with a plug sensor configured = not present:
384+
# never send (possibly billed) commands to a car that may be away
385+
plug_ok = plugged is True or (plugged is None and (not has_plug_sensor or charging))
386+
present = plug_ok and (home is not False)
387+
# a second source covers the primary one being empty (e.g. a fast
388+
# MQTT sensor that only refills when the car reports again)
375389
car_soc = number(self.hass, c.get(CONF_CAR_SOC_SENSOR))
390+
if car_soc is None:
391+
car_soc = number(self.hass, c.get(CONF_CAR_SOC_FALLBACK))
376392
limit = number(self.hass, c.get(CONF_CAR_LIMIT_ENTITY)) or float(
377393
c.get(CONF_CAR_LIMIT_DEFAULT, DEFAULT_CAR_LIMIT))
378394
car_in = P.CarInput(present=present, soc=car_soc, limit_soc=limit,

‎custom_components/surplus_pilot/manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
"iot_class": "local_polling",
1010
"issue_tracker": "https://github.com/xchillxx/surplus-pilot/issues",
1111
"requirements": [],
12-
"version": "3.0.2"
12+
"version": "3.0.3"
1313
}

‎custom_components/surplus_pilot/sources.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,22 @@ def is_on(hass: HomeAssistant, entity_id: str | None) -> bool | None:
5656
return st.state in ("on", "home", "true", "charging", "connected")
5757

5858

59+
_UNPLUGGED = {"off", "false", "disconnected", "unplugged", "not_connected", "no car connected", "none",
60+
"no_power", "nopower"}
61+
62+
63+
def plugged(hass: HomeAssistant, entity_id: str | None) -> bool | None:
64+
"""Plugged-in from a binary sensor (on/off) or a text sensor whose state
65+
names the cable/charge status (e.g. 'disconnected', 'complete',
66+
'charging'). None = unknown."""
67+
if not entity_id:
68+
return None
69+
st = hass.states.get(entity_id)
70+
if st is None or st.state in BAD:
71+
return None
72+
return st.state.strip().lower() not in _UNPLUGGED
73+
74+
5975
def age_minutes(hass: HomeAssistant, entity_id: str | None) -> float | None:
6076
"""Minutes since the sensor last reported (last_updated, not
6177
last_changed: a constant value that is still being reported is fresh)."""

‎custom_components/surplus_pilot/strings.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,15 @@
8181
"charge_switch": "Charging on/off switch",
8282
"current_entity": "Charging current (number)",
8383
"soc_sensor": "Car state of charge",
84+
"soc_fallback_sensor": "Car state of charge — fallback (optional)",
8485
"limit_entity": "Car charge limit (optional)",
8586
"limit_default": "Charge limit (fallback)",
8687
"plugged_sensor": "Plugged in",
88+
"plugged_fallback_sensor": "Plugged in — fallback (optional)",
8789
"charging_sensor": "Is charging",
8890
"power_sensor": "Charging power",
8991
"tracker": "Location (optional)",
92+
"tracker_fallback": "Location — fallback (optional)",
9093
"capacity_kwh": "Car battery capacity",
9194
"efficiency": "Charging efficiency",
9295
"min_amps": "Minimum current",
@@ -102,6 +105,8 @@
102105
"charge_switch": "On the car or on the wallbox.",
103106
"current_entity": "Ampere entity on the car or wallbox.",
104107
"soc_sensor": "Without it the car charges on surplus only, no departure targets.",
108+
"soc_fallback_sensor": "Used while the first one is unavailable (e.g. after a restart).",
109+
"plugged_fallback_sensor": "Binary sensor or a charge-status text sensor (e.g. 'disconnected' / 'complete').",
105110
"tracker": "Car only counts as present when 'home'.",
106111
"allow_grid": "Charge from the grid (cheapest slots, late) when PV can't reach a departure target.",
107112
"max_commands_per_day": "For paid vehicle APIs.",

‎custom_components/surplus_pilot/translations/de.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,15 @@
8181
"charge_switch": "Laden an/aus (Schalter)",
8282
"current_entity": "Ladestrom (Zahl)",
8383
"soc_sensor": "Auto-Ladestand",
84+
"soc_fallback_sensor": "Auto-Ladestand — Ersatzquelle (optional)",
8485
"limit_entity": "Ladelimit des Autos (optional)",
8586
"limit_default": "Ladelimit (Ersatzwert)",
8687
"plugged_sensor": "Angesteckt",
88+
"plugged_fallback_sensor": "Angesteckt — Ersatzquelle (optional)",
8789
"charging_sensor": "Lädt gerade",
8890
"power_sensor": "Ladeleistung",
8991
"tracker": "Standort (optional)",
92+
"tracker_fallback": "Standort — Ersatzquelle (optional)",
9093
"capacity_kwh": "Akkukapazität Auto",
9194
"efficiency": "Ladewirkungsgrad",
9295
"min_amps": "Mindeststrom",
@@ -102,6 +105,8 @@
102105
"charge_switch": "Am Auto oder an der Wallbox.",
103106
"current_entity": "Ampere-Entität am Auto oder an der Wallbox.",
104107
"soc_sensor": "Ohne ihn lädt das Auto nur Überschuss, ohne Abfahrtsziele.",
108+
"soc_fallback_sensor": "Wird genutzt, solange der erste nicht verfügbar ist (z. B. nach einem Neustart).",
109+
"plugged_fallback_sensor": "Binärsensor oder Ladestatus-Text (z. B. 'disconnected' / 'complete').",
105110
"tracker": "Auto zählt nur als da, wenn 'home'.",
106111
"allow_grid": "Aus dem Netz laden (günstigste Zeit, spät), wenn PV ein Abfahrtsziel nicht schafft.",
107112
"max_commands_per_day": "Für kostenpflichtige Fahrzeug-APIs.",

‎custom_components/surplus_pilot/translations/en.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,15 @@
8181
"charge_switch": "Charging on/off switch",
8282
"current_entity": "Charging current (number)",
8383
"soc_sensor": "Car state of charge",
84+
"soc_fallback_sensor": "Car state of charge — fallback (optional)",
8485
"limit_entity": "Car charge limit (optional)",
8586
"limit_default": "Charge limit (fallback)",
8687
"plugged_sensor": "Plugged in",
88+
"plugged_fallback_sensor": "Plugged in — fallback (optional)",
8789
"charging_sensor": "Is charging",
8890
"power_sensor": "Charging power",
8991
"tracker": "Location (optional)",
92+
"tracker_fallback": "Location — fallback (optional)",
9093
"capacity_kwh": "Car battery capacity",
9194
"efficiency": "Charging efficiency",
9295
"min_amps": "Minimum current",
@@ -102,6 +105,8 @@
102105
"charge_switch": "On the car or on the wallbox.",
103106
"current_entity": "Ampere entity on the car or wallbox.",
104107
"soc_sensor": "Without it the car charges on surplus only, no departure targets.",
108+
"soc_fallback_sensor": "Used while the first one is unavailable (e.g. after a restart).",
109+
"plugged_fallback_sensor": "Binary sensor or a charge-status text sensor (e.g. 'disconnected' / 'complete').",
105110
"tracker": "Car only counts as present when 'home'.",
106111
"allow_grid": "Charge from the grid (cheapest slots, late) when PV can't reach a departure target.",
107112
"max_commands_per_day": "For paid vehicle APIs.",

‎tests/test_integration.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,28 @@ async def test_config_flow(hass: HomeAssistant):
117117
assert r["type"] == "create_entry", r
118118
await hass.async_block_till_done()
119119
assert entry.data["devices"][0]["name"] == "Boiler"
120+
121+
122+
async def test_unknown_plug_state_means_not_present(hass: HomeAssistant, freezer):
123+
set_world(hass, pv=9.0, load=0.6, soc=96, car_soc=50)
124+
hass.states.async_set("binary_sensor.car_plugged", "unavailable")
125+
hass.states.async_set("sensor.car_soc", "unavailable")
126+
hass.states.async_set("sensor.car_soc_fleet", 55)
127+
hass.states.async_set("sensor.car_cable", "complete")
128+
for e in ("switch.car_charge", "switch.miner", "switch.pump", "climate.pool"):
129+
hass.states.async_set(e, "off")
130+
entry = MockConfigEntry(domain=DOMAIN, data=DATA)
131+
entry.add_to_hass(hass)
132+
assert await hass.config_entries.async_setup(entry.entry_id)
133+
await hass.async_block_till_done()
134+
co = hass.data[DOMAIN][entry.entry_id]
135+
assert co.status["auto_da"] is False # plug state unknown -> no commands
136+
car = {**DATA["car"], "soc_fallback_sensor": "sensor.car_soc_fleet", "plugged_fallback_sensor": "sensor.car_cable"}
137+
hass.config_entries.async_update_entry(entry, data={**DATA, "car": car})
138+
await hass.async_block_till_done()
139+
co = hass.data[DOMAIN][entry.entry_id]
140+
await co.async_refresh()
141+
assert co.status["auto_da"] is True and co.status["auto_soc"] == 55.0
142+
hass.states.async_set("sensor.car_cable", "disconnected")
143+
await co.async_refresh()
144+
assert co.status["auto_da"] is False

0 commit comments

Comments
 (0)