From 4564ebfaf9650eadb0efa9fc0b09f491eeb8a82e Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Mon, 6 Jul 2026 21:49:18 +1000 Subject: [PATCH] feat: add firmware_compare/firmware_at_least helper Ported from Home Assistant core PR #175745, which found that comparing Tesla firmware strings lexicographically (e.g. vehicle.firmware >= "2024.44.25") misorders week-based versions such as "2025.10" sorting below "2025.9", wrongly denying streaming entities to vehicles on newer firmware. firmware_compare(a, b) -> int compares dotted numeric components pairwise (returns 1/-1/0), right-padding shorter versions with zeros and treating unparseable strings (e.g. "Unknown") as sorting behind any parseable version. firmware_at_least(firmware, minimum) -> bool is a thin wrapper matching the HA helper's exact call signature for a drop-in replacement. Implemented as a native tuple comparison rather than adding an AwesomeVersion dependency, consistent with this library's narrow, purpose-built dependency list. --- AGENTS.md | 8 ++++ tesla_fleet_api/__init__.py | 3 ++ tesla_fleet_api/util.py | 51 +++++++++++++++++++++++ tests/test_firmware_at_least.py | 73 +++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 tesla_fleet_api/util.py create mode 100644 tests/test_firmware_at_least.py diff --git a/AGENTS.md b/AGENTS.md index c8a5536..d16ee53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,14 @@ Each API client lazily attaches submodules in `__init__` via class attributes on Scope flags on `TeslaFleetApi.__init__` control which submodules are instantiated. +### Shared Utilities + +`util.py` holds small, dependency-free helpers shared across the library, re-exported from the top-level package. `firmware_compare(a, b) -> int` compares dotted, numeric, week-based Tesla firmware version strings (e.g. `2025.14.3`) correctly — plain string comparison misorders them (`"2025.10" < "2025.9"`). It returns 1/-1/0, right-pads shorter versions with zeros before comparing, and treats unparseable strings (e.g. `"Unknown"`) as sorting behind any parseable version. `firmware_at_least(firmware, minimum) -> bool` is a thin wrapper (`firmware_compare(firmware, minimum) >= 0`) for the common "does this vehicle's firmware support feature X" gate — ported from Home Assistant core PR #175745, which fixed the same lexicographic bug in the `teslemetry` integration. Deliberately implemented as native tuple comparison rather than taking on an `AwesomeVersion` dependency, matching this library's narrow, purpose-built dependency list (no general-purpose version-parsing lib elsewhere). + +### Release Process + +No release-please or version-bump automation. To ship: bump `version` in `pyproject.toml` and `__version__` in `tesla_fleet_api/__init__.py` in a `Bump version to X.Y.Z` commit on `main`, then push a matching `vX.Y.Z` tag. `.github/workflows/python-publish.yml` runs on every push but only builds+publishes to PyPI (and creates a GitHub Release) when `github.ref` starts with `refs/tags/` — pushing the tag is what actually ships the release; merging to `main` alone does not. + ### Error Handling `exceptions.py` maps HTTP status codes and error keys to specific exception classes. `raise_for_status()` parses responses and raises the appropriate exception. Signed command faults have separate hierarchies: `TeslaFleetInformationFault`, `TeslaFleetMessageFault`, `SignedMessageInformationFault`, `WhitelistOperationStatus`. diff --git a/tesla_fleet_api/__init__.py b/tesla_fleet_api/__init__.py index 4f04199..54d6cdd 100644 --- a/tesla_fleet_api/__init__.py +++ b/tesla_fleet_api/__init__.py @@ -9,6 +9,7 @@ from tesla_fleet_api.tesla.oauth import TeslaFleetOAuth from tesla_fleet_api.teslemetry.teslemetry import Teslemetry from tesla_fleet_api.tessie.tessie import Tessie +from tesla_fleet_api.util import firmware_at_least, firmware_compare __all__ = [ "Region", @@ -17,5 +18,7 @@ "TeslaFleetOAuth", "Teslemetry", "Tessie", + "firmware_at_least", + "firmware_compare", "is_valid_region", ] diff --git a/tesla_fleet_api/util.py b/tesla_fleet_api/util.py new file mode 100644 index 0000000..d2f1510 --- /dev/null +++ b/tesla_fleet_api/util.py @@ -0,0 +1,51 @@ +"""Shared utility helpers.""" + + +def _parse_firmware(version: str) -> tuple[int, ...] | None: + """Parse a dotted numeric firmware string, or None if it doesn't parse.""" + try: + return tuple(int(part) for part in version.split(".")) + except ValueError: + return None + + +def firmware_compare(a: str, b: str) -> int: + """Compare two Tesla firmware versions. + + Tesla firmware versions are dotted, numeric, and week-based (e.g. + ``2025.14.3``), so comparing them as plain strings misorders results: + ``"2025.10" < "2025.9"`` as strings, even though ``2025.10`` is the newer + release. This compares the dotted numeric components pairwise instead. + + Returns 1 if ``a`` is ahead of ``b``, -1 if ``a`` is behind ``b``, and 0 + if they are equal. Versions with fewer components than the other are + right-padded with zeros, so ``"2024.26"`` compares equal to + ``"2024.26.0"`` and behind ``"2024.26.25"``. A firmware string that + doesn't parse as dotted integers (e.g. ``"Unknown"``) sorts behind any + parseable version, and equal to another unparseable string. + """ + a_parts = _parse_firmware(a) + b_parts = _parse_firmware(b) + if a_parts is None or b_parts is None: + if a_parts is None and b_parts is None: + return 0 + return -1 if a_parts is None else 1 + length = max(len(a_parts), len(b_parts)) + a_parts += (0,) * (length - len(a_parts)) + b_parts += (0,) * (length - len(b_parts)) + if a_parts < b_parts: + return -1 + if a_parts > b_parts: + return 1 + return 0 + + +def firmware_at_least(firmware: str, minimum: str) -> bool: + """Return True if a Tesla firmware version is at least a minimum version. + + Thin wrapper around :func:`firmware_compare` for the common "does this + vehicle's firmware support feature X" check. See ``firmware_compare`` for + the comparison semantics, including how partial versions and unparseable + strings (e.g. ``"Unknown"``) are handled. + """ + return firmware_compare(firmware, minimum) >= 0 diff --git a/tests/test_firmware_at_least.py b/tests/test_firmware_at_least.py new file mode 100644 index 0000000..0be106d --- /dev/null +++ b/tests/test_firmware_at_least.py @@ -0,0 +1,73 @@ +"""Tests for firmware_compare/firmware_at_least — dotted numeric firmware comparison. + +Ported from Home Assistant core PR #175745, which fixed lexicographic string +comparisons (e.g. ``vehicle.firmware >= "2024.44.25"``) that misorder +week-based Tesla firmware versions such as "2025.10" sorting below "2025.9". +""" + +from unittest import TestCase + +from tesla_fleet_api import firmware_at_least, firmware_compare + + +class FirmwareCompareTests(TestCase): + def test_lexicographic_bug_case(self) -> None: + # "2025.10" < "2025.9" as plain strings, but 2025.10 is newer. + self.assertEqual(firmware_compare("2025.10", "2025.9"), 1) + self.assertEqual(firmware_compare("2025.9", "2025.10"), -1) + + def test_equal_versions(self) -> None: + self.assertEqual(firmware_compare("2024.8", "2024.8"), 0) + self.assertEqual(firmware_compare("2024.44.25", "2024.44.25"), 0) + + def test_partial_version_comparisons(self) -> None: + self.assertEqual(firmware_compare("2025.14", "2025.2.6"), 1) + self.assertEqual(firmware_compare("2025.2.6", "2025.14"), -1) + self.assertEqual(firmware_compare("2024.44.25", "2024.8"), 1) + self.assertEqual(firmware_compare("2024.8", "2024.44.25"), -1) + + def test_missing_trailing_components_treated_as_zero(self) -> None: + self.assertEqual(firmware_compare("2024.26", "2024.26"), 0) + self.assertEqual(firmware_compare("2024.26.0", "2024.26"), 0) + self.assertEqual(firmware_compare("2024.26", "2024.26.25"), -1) + self.assertEqual(firmware_compare("2024.26.25", "2024.26"), 1) + + def test_unparseable_firmware_sorts_behind_parseable(self) -> None: + self.assertEqual(firmware_compare("Unknown", "2024.8"), -1) + self.assertEqual(firmware_compare("2024.8", "Unknown"), 1) + self.assertEqual(firmware_compare("Unknown", "Unknown"), 0) + self.assertEqual(firmware_compare("", "2024.8"), -1) + + +class FirmwareAtLeastTests(TestCase): + def test_lexicographic_bug_case(self) -> None: + self.assertTrue(firmware_at_least("2025.10", "2025.9")) + self.assertFalse(firmware_at_least("2025.9", "2025.10")) + + def test_equal_versions(self) -> None: + self.assertTrue(firmware_at_least("2024.8", "2024.8")) + self.assertTrue(firmware_at_least("2024.44.25", "2024.44.25")) + + def test_partial_version_comparisons(self) -> None: + self.assertTrue(firmware_at_least("2025.14", "2025.2.6")) + self.assertFalse(firmware_at_least("2025.2.6", "2025.14")) + self.assertTrue(firmware_at_least("2024.44.25", "2024.8")) + self.assertFalse(firmware_at_least("2024.8", "2024.44.25")) + + def test_missing_trailing_components_treated_as_zero(self) -> None: + self.assertTrue(firmware_at_least("2024.26", "2024.26")) + self.assertTrue(firmware_at_least("2024.26.0", "2024.26")) + self.assertFalse(firmware_at_least("2024.26", "2024.26.25")) + self.assertTrue(firmware_at_least("2024.26.25", "2024.26")) + + def test_streaming_firmware_thresholds_from_ha_pr(self) -> None: + # Real minimum-version gates used by the Teslemetry HA integration. + self.assertTrue(firmware_at_least("2024.44.25", "2024.44.25")) + self.assertFalse(firmware_at_least("2024.44.24", "2024.44.25")) + self.assertTrue(firmware_at_least("2024.26", "2024.26")) + self.assertFalse(firmware_at_least("2024.25.99", "2024.26")) + + def test_unparseable_firmware_does_not_meet_minimum(self) -> None: + self.assertFalse(firmware_at_least("Unknown", "2024.8")) + self.assertFalse(firmware_at_least("Unknown", "2025.14")) + self.assertFalse(firmware_at_least("", "2024.8"))