Skip to content
Merged
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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
3 changes: 3 additions & 0 deletions tesla_fleet_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -17,5 +18,7 @@
"TeslaFleetOAuth",
"Teslemetry",
"Tessie",
"firmware_at_least",
"firmware_compare",
"is_valid_region",
]
51 changes: 51 additions & 0 deletions tesla_fleet_api/util.py
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions tests/test_firmware_at_least.py
Original file line number Diff line number Diff line change
@@ -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"))
Loading