Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions carwatch/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,9 +364,10 @@ def _think(question: str, asker: str) -> str:
f"({_fu.get('range_km', 0):.0f} km range)")
_ty = _me.get("tires_kpa") or {}
if _ty:
_bits.append("tyre pressures kPa " + ", ".join(
f"{k.replace('_', ' ')} {v:.0f}"
for k, v in _ty.items()))
from carwatch.mercedesme import fmt_tyres_bar
_tt = fmt_tyres_bar(_ty)
if _tt:
_bits.append("tyre pressures " + _tt)
_lk = (_me.get("lock") or {}).get("locked")
if _lk:
_bits.append(f"doors {_lk}")
Expand Down
68 changes: 65 additions & 3 deletions carwatch/mercedesme.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,15 +223,75 @@ def _numeric(state: str):
return None


def _norm_value(entity_id: str, state: str):
"""HA state string -> honest normalized value."""
# Tyre pressure arrives in whatever unit the owner's Home Assistant is set to.
# We used to file it under "tires_kpa" regardless, so a psi reading became a
# "kPa" number and the agent told petrus his tyres were at THIRTY BAR
# (issue #39). The unit is in the entity attributes and we were throwing it
# away. Convert explicitly, and refuse rather than guess on an unknown unit.
_PRESSURE_TO_KPA = {
"kpa": 1.0,
"bar": 100.0,
"psi": 6.894757,
"mbar": 0.1,
"hpa": 0.1,
}


def _pressure_kpa(value: float, unit):
"""Pressure in ANY reported unit -> kPa. None when the unit is unknown."""
if value is None:
return None
factor = _PRESSURE_TO_KPA.get(str(unit or "").strip().lower())
if factor is None:
return None # fail closed: an unlabelled pressure is not a kPa
return round(value * factor, 1)


_WHEEL_ORDER = ("front_left", "front_right", "rear_left", "rear_right")
# A passenger-car tyre lives around 2.0-3.0 bar. Anything outside this is a
# sensor or unit problem, and saying it flatly is how "thirty bar" reached the
# driver. Flag it instead of stating it as fact.
_TYRE_BAR_MIN, _TYRE_BAR_MAX = 1.0, 4.5


def fmt_tyres_bar(tires_kpa: dict) -> str:
"""kPa readings -> 'front left 2.4 bar, ...'.

We do the conversion so the model never has to. Handing it a raw number
and a unit name is what produced a ten-times-too-large answer.
"""
if not tires_kpa:
return ""
keys = [k for k in _WHEEL_ORDER if k in tires_kpa]
keys += [k for k in tires_kpa if k not in _WHEEL_ORDER]
parts = []
for k in keys:
v = tires_kpa.get(k)
if v is None:
continue
bar = v / 100.0
txt = f"{k.replace('_', ' ')} {bar:.1f} bar"
if not (_TYRE_BAR_MIN <= bar <= _TYRE_BAR_MAX):
txt += " (IMPLAUSIBLE - report as a sensor fault, do not state it as the pressure)"
parts.append(txt)
return ", ".join(parts)


def _norm_value(entity_id: str, state: str, unit=None, to_kpa: bool = False):
"""HA state string -> honest normalized value.

`to_kpa` marks a pressure entity, whose numeric state is only meaningful
together with its unit_of_measurement.
"""
if state in ("unknown", "unavailable", "", None):
return None
if entity_id.startswith(("binary_sensor.", "lock.")):
# locked/unlocked, on/off, open/closed pass through as-is - the
# dashboard renders words, inventing booleans loses "jammed" etc.
return state
n = _numeric(state)
if to_kpa:
return _pressure_kpa(n, unit)
return state if n is None else n


Expand Down Expand Up @@ -339,7 +399,9 @@ def status(self) -> dict:
str(attrs.get("friendly_name", "car")).split()[0].lower()
car = cars.setdefault(slug, {"label": slug})
hits_per_slug[slug] = hits_per_slug.get(slug, 0) + 1
val = _norm_value(eid, ent.get("state"))
val = _norm_value(eid, ent.get("state"),
attrs.get("unit_of_measurement"),
to_kpa=(grp == "tires_kpa"))
if val is None:
continue
if grp == "_flat":
Expand Down
5 changes: 4 additions & 1 deletion carwatch/webchat.py
Original file line number Diff line number Diff line change
Expand Up @@ -1165,7 +1165,10 @@ def answer(question: str, use_manual: bool = True) -> str:
_label = f"{_label} (the household's OTHER car, not you)"
_t = _car.get("tires_kpa")
if _t:
_bits.append(f"{_label}: tyres {_t} kPa")
from carwatch.mercedesme import fmt_tyres_bar
_tt = fmt_tyres_bar(_t)
if _tt:
_bits.append(f"{_label}: tyres {_tt}")
_fu = _car.get("fuel") or {}
_ev = _car.get("ev") or {}
_fparts = []
Expand Down
64 changes: 64 additions & 0 deletions tests/test_tyre_units.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Issue #39: the car agent told petrus his tyres were at THIRTY BAR.

Root cause: Home Assistant reports tyre pressure in whatever unit the owner's
HA is configured for. The fetch read `attributes.unit_of_measurement` and then
threw it away, filing the value under `tires_kpa` regardless, and the prompt
handed the model a bare number plus a unit word to convert for itself.
"""
import os
import sys
import unittest

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from carwatch.mercedesme import ( # noqa: E402
_norm_value, _pressure_kpa, fmt_tyres_bar,
)


class TyrePressureUnits(unittest.TestCase):

def test_converts_from_every_unit_ha_might_report(self):
self.assertEqual(_pressure_kpa(2.4, "bar"), 240.0)
self.assertEqual(_pressure_kpa(240, "kPa"), 240.0)
self.assertEqual(round(_pressure_kpa(35, "psi")), 241)
self.assertEqual(_pressure_kpa(2400, "mbar"), 240.0)

def test_unknown_unit_refuses_rather_than_assuming_kpa(self):
# The old code assumed kPa, which is how a psi reading became a "kPa"
# value. An unlabelled pressure must not be invented.
self.assertIsNone(_pressure_kpa(30, None))
self.assertIsNone(_pressure_kpa(30, ""))
self.assertIsNone(_pressure_kpa(30, "furlongs"))

def test_psi_entity_no_longer_becomes_ten_times_too_large(self):
kpa = _norm_value("sensor.eclass_tirepressure_front_left", "30",
"psi", to_kpa=True)
bar = kpa / 100.0
self.assertTrue(1.9 < bar < 2.2, f"30 psi should be ~2.1 bar, got {bar}")
self.assertIn("2.1 bar", fmt_tyres_bar({"front_left": kpa}))

def test_output_is_bar_one_decimal_and_model_never_converts(self):
out = fmt_tyres_bar({"front_left": 250, "front_right": 250,
"rear_left": 260, "rear_right": 260})
self.assertEqual(out, "front left 2.5 bar, front right 2.5 bar, "
"rear left 2.6 bar, rear right 2.6 bar")
self.assertNotIn("kPa", out)

def test_implausible_pressure_is_flagged_not_stated(self):
self.assertIn("IMPLAUSIBLE", fmt_tyres_bar({"front_left": 3000}))
self.assertIn("IMPLAUSIBLE", fmt_tyres_bar({"front_left": 30}))

def test_wheels_ordered_and_missing_values_skipped(self):
out = fmt_tyres_bar({"rear_right": 260, "front_left": 250,
"front_right": None})
self.assertTrue(out.startswith("front left"))
self.assertNotIn("front right", out)

def test_non_pressure_entities_are_untouched(self):
self.assertEqual(_norm_value("lock.eclass", "locked"), "locked")
self.assertEqual(_norm_value("sensor.eclass_odometer", "48211"), 48211.0)


if __name__ == "__main__":
unittest.main()
Loading