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
41 changes: 40 additions & 1 deletion custom_components/geely_global/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@

from typing import Any

from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
Expand Down Expand Up @@ -102,6 +106,38 @@
("distance_to_service", "Distance To Service", (*_MAINT, "distanceToService"), UnitOfLength.KILOMETERS, SensorDeviceClass.DISTANCE, "int", None),
)

# Long-term statistics only exist for sensors that declare a state_class:
# without one the recorder purges the history after ~10 days and the sensor
# never shows up in the statistics graphs. Nothing warns about it.
# The three mapped sensors (engine_state, park_brake, charger_connected) are
# absent on purpose - they report a label, not a number, and a state class on a
# textual sensor is meaningless (and outright rejected by HA if they ever gain
# device_class ENUM).
_STATE_CLASSES: dict[str, SensorStateClass] = {
"battery": SensorStateClass.MEASUREMENT,
"range": SensorStateClass.MEASUREMENT,
# The odometer and the trip meter are counters, not readings. As plain
# measurements HA would average the dashboard number, which says nothing;
# TOTAL_INCREASING makes it report distance driven per period, and it
# absorbs the trip meter being reset to zero as the start of a new cycle.
"total_mileage": SensorStateClass.TOTAL_INCREASING,
"trip_meter": SensorStateClass.TOTAL_INCREASING,
"interior_temp": SensorStateClass.MEASUREMENT,
"exterior_temp": SensorStateClass.MEASUREMENT,
"speed": SensorStateClass.MEASUREMENT,
"time_to_full_min": SensorStateClass.MEASUREMENT,
"12v_battery": SensorStateClass.MEASUREMENT,
"12v_voltage": SensorStateClass.MEASUREMENT,
"avg_consumption": SensorStateClass.MEASUREMENT,
"avg_speed": SensorStateClass.MEASUREMENT,
"tire_pressure_fl": SensorStateClass.MEASUREMENT,
"tire_pressure_fr": SensorStateClass.MEASUREMENT,
"tire_pressure_rl": SensorStateClass.MEASUREMENT,
"tire_pressure_rr": SensorStateClass.MEASUREMENT,
"days_to_service": SensorStateClass.MEASUREMENT,
"distance_to_service": SensorStateClass.MEASUREMENT,
}

# Sensors marked diagnostic appear in HA's collapsed "Diagnostic" section
# on the device page rather than the main entity list.
_DIAGNOSTIC_KEYS: set[str] = {
Expand Down Expand Up @@ -166,6 +202,9 @@ def __init__(self, coordinator, vin: str, device_name: str,
self._attr_native_unit_of_measurement = unit
if device_class is not None:
self._attr_device_class = device_class
state_class = _STATE_CLASSES.get(key)
if state_class is not None:
self._attr_state_class = state_class
icon = _SENSOR_ICONS.get(key)
if icon:
self._attr_icon = icon
Expand Down
148 changes: 148 additions & 0 deletions tests/test_state_class.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Every numeric sensor must declare a state_class, and no textual one may.

The failure this guards against is silent in both directions: a sensor without
state_class is simply absent from HA's long-term statistics (no error, no log,
the history just stops at the recorder's ~10-day purge), and a state class on a
sensor that reports a label is meaningless - and, the day those sensors gain
device_class ENUM, makes HA drop the entity with a validation error at startup.

The allowed combinations below are transcribed from DEVICE_CLASS_STATE_CLASSES
in homeassistant/components/sensor/const.py (read against HA 2026.7.4). Keys and
device classes are read from sensor.py rather than listed here, so this test
tracks the code instead of a copy of it.

Run: python3 tests/test_state_class.py
"""
import ast
import os
import pathlib
import sys

HERE = pathlib.Path(os.path.dirname(os.path.abspath(__file__)))
COMPONENT = HERE.parent / "custom_components" / "geely_global"

# device_class -> state classes HA accepts for it. A device class the code uses
# but that is missing here fails the test on purpose: look it up in HA's table
# before adding it. `None` (no device class) accepts anything.
ALLOWED: dict[str | None, set[str]] = {
"BATTERY": {"MEASUREMENT"},
"DISTANCE": {"MEASUREMENT", "MEASUREMENT_ANGLE", "TOTAL", "TOTAL_INCREASING"},
"ENUM": set(),
"PRESSURE": {"MEASUREMENT"},
"SPEED": {"MEASUREMENT"},
"TEMPERATURE": {"MEASUREMENT"},
"VOLTAGE": {"MEASUREMENT"},
}


def _tree():
return ast.parse((COMPONENT / "sensor.py").read_text())


def _enum_name(node):
"""SensorDeviceClass.BATTERY -> "BATTERY"; None -> None."""
if isinstance(node, ast.Attribute):
return node.attr
return None


def _specs():
"""[(key, device_class_name_or_None, value_type), ...] from SENSOR_SPECS.

Row layout: (key, friendly_name, path, unit, device_class, value_type, map).
"""
for node in ast.walk(_tree()):
target = getattr(node, "target", None) or (
node.targets[0] if isinstance(node, ast.Assign) else None
)
if isinstance(target, ast.Name) and target.id == "SENSOR_SPECS":
return [
(e.elts[0].value, _enum_name(e.elts[4]), e.elts[5].value)
for e in node.value.elts
]
raise AssertionError("SENSOR_SPECS not found in sensor.py")


def _state_classes():
"""{key: state_class_name} from _STATE_CLASSES."""
for node in ast.walk(_tree()):
target = getattr(node, "target", None) or (
node.targets[0] if isinstance(node, ast.Assign) else None
)
if isinstance(target, ast.Name) and target.id == "_STATE_CLASSES":
return {
k.value: _enum_name(v)
for k, v in zip(node.value.keys, node.value.values)
}
raise AssertionError("_STATE_CLASSES not found in sensor.py")


def test_every_numeric_sensor_has_a_state_class():
"""Without it the sensor never reaches long-term statistics."""
declared = _state_classes()
missing = [
key for key, _device_class, value_type in _specs()
if value_type != "map" and key not in declared
]
assert not missing, f"sensors with no state_class: {missing}"


def test_no_textual_sensor_declares_a_state_class():
"""A mapped sensor reports a label; statistics over labels mean nothing,
and HA rejects the entity outright once the device class is ENUM."""
declared = _state_classes()
offenders = [
key for key, _device_class, value_type in _specs()
if value_type == "map" and key in declared
]
assert not offenders, f"textual sensors with a state_class: {offenders}"


def test_state_classes_are_valid_for_their_device_class():
declared = _state_classes()
for key, device_class, _value_type in _specs():
state_class = declared.get(key)
if state_class is None:
continue
assert device_class in ALLOWED or device_class is None, (
f"{key}: device_class {device_class} is not in this test's copy of "
"HA's table - check DEVICE_CLASS_STATE_CLASSES before adding it"
)
if device_class is None:
continue
assert state_class in ALLOWED[device_class], (
f"{key}: HA does not accept state_class {state_class} on a "
f"{device_class} sensor (allowed: {sorted(ALLOWED[device_class]) or 'none'})"
)


def test_no_state_class_is_left_over():
"""A key here but not in SENSOR_SPECS is dead weight - usually a rename."""
keys = {key for key, _device_class, _value_type in _specs()}
extra = sorted(set(_state_classes()) - keys)
assert not extra, f"_STATE_CLASSES has stale keys {extra}"


def test_the_odometer_accumulates():
"""total_mileage and trip_meter feed "distance driven" statistics; as plain
measurements HA would average them, which means nothing for a counter."""
declared = _state_classes()
for key in ("total_mileage", "trip_meter"):
assert declared.get(key) == "TOTAL_INCREASING", (
f"{key} is {declared.get(key)}, expected TOTAL_INCREASING"
)


if __name__ == "__main__":
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
failed = 0
for t in tests:
try:
t()
print(f"ok {t.__name__}")
except AssertionError as e:
failed += 1
print(f"FAIL {t.__name__}: {e}")
print()
print(f"{len(tests) - failed}/{len(tests)} passaram")
sys.exit(1 if failed else 0)