diff --git a/.github/scripts/si_canonical_gate.py b/.github/scripts/si_canonical_gate.py index ae1cfc6ac3..f1e05af207 100644 --- a/.github/scripts/si_canonical_gate.py +++ b/.github/scripts/si_canonical_gate.py @@ -50,6 +50,16 @@ "web/src/hooks/useSettings.test.ts", } +# Tesla Fleet Telemetry Field enum identifiers are upstream-owned (ADR-004 R2). +# Wire names may contain Kwh/Mph even though TeslaSync stores SI (Wh, m/s). +TESLA_PROTO_FIELD_ALLOWLIST = ( + "NominalFullPackEnergyKwh", + "LifetimeEnergyChargedKwh", + "MaxSpeedToReachDestinationMph", + "SemiCruiseSpeedLimitMph", + "ChargeRateMilePerHour", +) + def iter_added_lines(diff_text: str): current_file = None @@ -77,6 +87,8 @@ def check_go(diff_text: str) -> list[str]: s = added.lstrip() if s.startswith("//") or s.startswith("#") or s.startswith("--"): continue + if any(name in added for name in TESLA_PROTO_FIELD_ALLOWLIST): + continue for pat in BANNED: if pat.search(added): violations.append(f"{current_file}: {added.rstrip()}") diff --git a/.github/workflows/codec-coverage-gate.yml b/.github/workflows/codec-coverage-gate.yml index 2ec9958be4..6dd0f5fd0d 100644 --- a/.github/workflows/codec-coverage-gate.yml +++ b/.github/workflows/codec-coverage-gate.yml @@ -46,7 +46,9 @@ permissions: jobs: routing-coverage: name: TestRoutingCoverage (every proto field is routed) - runs-on: ${{ inputs.runner || 'arc-runner' }} + # ubuntu-latest: `go test -race` needs CGO/gcc. arc-runner does not ship gcc + # (same pin as CI "Backend (lint + test + build)"). + runs-on: ${{ github.event_name == 'workflow_dispatch' && inputs.runner || 'ubuntu-latest' }} steps: - uses: actions/checkout@v4 diff --git a/api/proto/tesla/CHECKSUM b/api/proto/tesla/CHECKSUM index 38ed0d7a04..638bde8b08 100644 --- a/api/proto/tesla/CHECKSUM +++ b/api/proto/tesla/CHECKSUM @@ -1 +1 @@ -4596EBA1D26B72EB69BBF747831BF0EB2EEC106A54048D9BC7D6DE765446697C +E8BE013478FC24DADE3B40DF10A269E6477D27FABB95C641A575A5214A101618 diff --git a/api/proto/tesla/SOURCE.md b/api/proto/tesla/SOURCE.md index e99b7e32ed..34a4f28137 100644 --- a/api/proto/tesla/SOURCE.md +++ b/api/proto/tesla/SOURCE.md @@ -17,14 +17,14 @@ rationale. |---|---| | Upstream repository | `github.com/teslamotors/fleet-telemetry` | | Upstream path | `protos/vehicle_data.proto` | -| Upstream tag | **v0.8.0** | -| Upstream commit | `031553a7d3d6952c1552ed13dc71aaf4fd4a882b` | -| Upstream commit date | 2025-12-12 | -| Upstream raw URL | | -| Upstream tree URL | | -| File size | 21,291 bytes | -| SHA256 | `4596EBA1D26B72EB69BBF747831BF0EB2EEC106A54048D9BC7D6DE765446697C` | -| Fetched on | 2026-05-01 | +| Upstream tag | untagged (`Add new streamable fields 260-269`, #546) | +| Upstream commit | `8fbaa100bd365936dab6ecbf0e2d7070c4d765cb` | +| Upstream commit date | 2026-09-14 | +| Upstream raw URL | | +| Upstream tree URL | | +| File size | 21,729 bytes | +| SHA256 | `E8BE013478FC24DADE3B40DF10A269E6477D27FABB95C641A575A5214A101618` | +| Fetched on | 2026-09-14 | | Vendored by | phase-42 prompt 0010 | | Reviewed by | Staff Engineer + Principal Engineer + Principal Architect (per ADR-004) | diff --git a/api/proto/tesla/VERSION b/api/proto/tesla/VERSION index aa5a0c90ff..2d2af1c250 100644 --- a/api/proto/tesla/VERSION +++ b/api/proto/tesla/VERSION @@ -1,3 +1,3 @@ -teslamotors/fleet-telemetry@v0.8.0 -commit=031553a7d3d6952c1552ed13dc71aaf4fd4a882b -fetched=2026-05-01 +teslamotors/fleet-telemetry@8fbaa100bd365936dab6ecbf0e2d7070c4d765cb +commit=8fbaa100bd365936dab6ecbf0e2d7070c4d765cb +fetched=2026-09-14 diff --git a/api/proto/tesla/vehicle_data.proto b/api/proto/tesla/vehicle_data.proto index b73a63ef00..5783ae2657 100644 --- a/api/proto/tesla/vehicle_data.proto +++ b/api/proto/tesla/vehicle_data.proto @@ -284,6 +284,19 @@ enum Field { MilesSinceReset = 258; SelfDrivingMilesSinceReset = 259; + + // fields 260-269 are first available in firmware version 2026.32 (device client version 1.3.0) + + GpsAccuracyMeters = 260; + LifetimeEnergyChargedKwh = 261; + BrickSocMinPercent = 262; + NominalFullPackEnergyKwh = 263; + GradeEstimatePercent = 264; + MaxSpeedToReachDestinationMph = 265; + SoftwareUpdateAvailable = 266; + SoftwareUpdateInProgress = 267; + RemoteStartActive = 268; + SemiCruiseSpeedLimitMph = 269; // Semi-truck only } // ChargingState is deprecated and not used diff --git a/cmd/protogen-tesla/emit.go b/cmd/protogen-tesla/emit.go index af9a61c7b9..bade2c8b5a 100644 --- a/cmd/protogen-tesla/emit.go +++ b/cmd/protogen-tesla/emit.go @@ -569,6 +569,29 @@ func classifyExplicit(name string) fieldClass { return fieldClass{cat: "safety_security", kind: "float", unit: "distance", isSettingUnit: false} case "SelfDrivingMilesSinceReset": return fieldClass{cat: "safety_security", kind: "float", unit: "distance", isSettingUnit: false} + case "GpsAccuracyMeters": + // Wire unit is already metres (name-encoded). Do not treat as + // SettingDistanceUnit-following distance — ToSI would re-scale. + return fieldClass{cat: "location", kind: "float", unit: "none", isSettingUnit: false} + case "LifetimeEnergyChargedKwh": + return fieldClass{cat: "charging", kind: "float", unit: "none", isSettingUnit: false} + case "BrickSocMinPercent": + return fieldClass{cat: "charging", kind: "float", unit: "charge", isSettingUnit: false} + case "NominalFullPackEnergyKwh": + return fieldClass{cat: "charging", kind: "float", unit: "none", isSettingUnit: false} + case "GradeEstimatePercent": + return fieldClass{cat: "driving", kind: "float", unit: "none", isSettingUnit: false} + case "MaxSpeedToReachDestinationMph": + // Name-encoded mph; converted to m/s via fixedMphSpeedFields. + return fieldClass{cat: "driving", kind: "float", unit: "none", isSettingUnit: false} + case "SoftwareUpdateAvailable": + return fieldClass{cat: "vehicle_state", kind: "bool", unit: "none", isSettingUnit: false} + case "SoftwareUpdateInProgress": + return fieldClass{cat: "vehicle_state", kind: "bool", unit: "none", isSettingUnit: false} + case "RemoteStartActive": + return fieldClass{cat: "vehicle_state", kind: "bool", unit: "none", isSettingUnit: false} + case "SemiCruiseSpeedLimitMph": + return fieldClass{cat: "driving", kind: "float", unit: "none", isSettingUnit: false} } return fieldClass{} } diff --git a/internal/tesla/normalize/normalize.go b/internal/tesla/normalize/normalize.go index e3170516d4..54da3c3776 100644 --- a/internal/tesla/normalize/normalize.go +++ b/internal/tesla/normalize/normalize.go @@ -149,7 +149,8 @@ func (p *Pipeline) toSI(ctx context.Context, atomic codec.Atomic, vehicleIntID i // kWh/kW. Neither family may be dropped because a vehicle has no // unit_history row. if units.IsFixedMileDistanceField(atomic.Field) || - units.IsFixedKiloToBaseField(atomic.Field) { + units.IsFixedKiloToBaseField(atomic.Field) || + units.IsFixedMphSpeedField(atomic.Field) { raw, ok := coerceFloat(atomic.Value) if !ok { return codec.Atomic{}, fmt.Errorf("%w: %s value of type %T not coercible to float64", units.ErrUnsupportedField, atomic.Field, atomic.Value) @@ -218,7 +219,7 @@ func (p *Pipeline) toSI(ctx context.Context, atomic codec.Atomic, vehicleIntID i // them. The SettingChargeUnit signal is recorded for UI display // preference only. func needsConversion(field string, meta *protomodel.SignalMeta) bool { - if isSpeedField(field) || units.IsFixedKiloToBaseField(field) { + if isSpeedField(field) || units.IsFixedKiloToBaseField(field) || units.IsFixedMphSpeedField(field) { return true } if meta == nil { diff --git a/internal/tesla/protomodel/coverage_test.go b/internal/tesla/protomodel/coverage_test.go index 1b58456d04..a9a9147213 100644 --- a/internal/tesla/protomodel/coverage_test.go +++ b/internal/tesla/protomodel/coverage_test.go @@ -163,6 +163,39 @@ func TestCoverage_SettingUnitInvariants(t *testing.T) { // (e.g. a temperature reading) gets misclassified into a non-measurement // bucket and then gets silently dropped by the unit-history / // unit-conversion layer, which only iterates measurement-bucket fields. +// TestCoverage_FleetTelemetryFields260to269 pins the firmware-2026.32 Field +// enum values (teslamotors/fleet-telemetry@8fbaa100) on the vendored generator +// output. The teslamotors Go module is still v0.9.0 (Go 1.25), so +// TestCoverage_EveryProtoFieldHasSignalMeta cannot see these names via +// ftproto.Field_name until that module pin moves. +func TestCoverage_FleetTelemetryFields260to269(t *testing.T) { + want := []struct { + num int32 + name string + }{ + {260, "GpsAccuracyMeters"}, + {261, "LifetimeEnergyChargedKwh"}, + {262, "BrickSocMinPercent"}, + {263, "NominalFullPackEnergyKwh"}, + {264, "GradeEstimatePercent"}, + {265, "MaxSpeedToReachDestinationMph"}, + {266, "SoftwareUpdateAvailable"}, + {267, "SoftwareUpdateInProgress"}, + {268, "RemoteStartActive"}, + {269, "SemiCruiseSpeedLimitMph"}, + } + for _, w := range want { + meta, ok := SignalsByEnum[w.num] + if !ok { + t.Errorf("SignalsByEnum missing enum=%d (%s); re-vendor proto and run go generate", w.num, w.name) + continue + } + if meta.Field != w.name { + t.Errorf("SignalsByEnum[%d].Field=%q want %q", w.num, meta.Field, w.name) + } + } +} + func TestCoverage_UnitBearingFieldsAreMeasurements(t *testing.T) { denied := sortedKeys(nonMeasurementCategories) diff --git a/internal/tesla/protomodel/signal_metadata_gen.go b/internal/tesla/protomodel/signal_metadata_gen.go index e23e392658..ff382cacf1 100644 --- a/internal/tesla/protomodel/signal_metadata_gen.go +++ b/internal/tesla/protomodel/signal_metadata_gen.go @@ -270,6 +270,16 @@ const ( Field_Deprecated_3 Field = 257 Field_MilesSinceReset Field = 258 Field_SelfDrivingMilesSinceReset Field = 259 + Field_GpsAccuracyMeters Field = 260 + Field_LifetimeEnergyChargedKwh Field = 261 + Field_BrickSocMinPercent Field = 262 + Field_NominalFullPackEnergyKwh Field = 263 + Field_GradeEstimatePercent Field = 264 + Field_MaxSpeedToReachDestinationMph Field = 265 + Field_SoftwareUpdateAvailable Field = 266 + Field_SoftwareUpdateInProgress Field = 267 + Field_RemoteStartActive Field = 268 + Field_SemiCruiseSpeedLimitMph Field = 269 ) // Name returns the protobuf field name for this Field. Returns the empty @@ -796,6 +806,26 @@ func (f Field) Name() string { return "MilesSinceReset" case Field_SelfDrivingMilesSinceReset: return "SelfDrivingMilesSinceReset" + case Field_GpsAccuracyMeters: + return "GpsAccuracyMeters" + case Field_LifetimeEnergyChargedKwh: + return "LifetimeEnergyChargedKwh" + case Field_BrickSocMinPercent: + return "BrickSocMinPercent" + case Field_NominalFullPackEnergyKwh: + return "NominalFullPackEnergyKwh" + case Field_GradeEstimatePercent: + return "GradeEstimatePercent" + case Field_MaxSpeedToReachDestinationMph: + return "MaxSpeedToReachDestinationMph" + case Field_SoftwareUpdateAvailable: + return "SoftwareUpdateAvailable" + case Field_SoftwareUpdateInProgress: + return "SoftwareUpdateInProgress" + case Field_RemoteStartActive: + return "RemoteStartActive" + case Field_SemiCruiseSpeedLimitMph: + return "SemiCruiseSpeedLimitMph" } return "" } @@ -1334,6 +1364,26 @@ func ParseField(s string) (Field, error) { return Field_MilesSinceReset, nil case "SelfDrivingMilesSinceReset": return Field_SelfDrivingMilesSinceReset, nil + case "GpsAccuracyMeters": + return Field_GpsAccuracyMeters, nil + case "LifetimeEnergyChargedKwh": + return Field_LifetimeEnergyChargedKwh, nil + case "BrickSocMinPercent": + return Field_BrickSocMinPercent, nil + case "NominalFullPackEnergyKwh": + return Field_NominalFullPackEnergyKwh, nil + case "GradeEstimatePercent": + return Field_GradeEstimatePercent, nil + case "MaxSpeedToReachDestinationMph": + return Field_MaxSpeedToReachDestinationMph, nil + case "SoftwareUpdateAvailable": + return Field_SoftwareUpdateAvailable, nil + case "SoftwareUpdateInProgress": + return Field_SoftwareUpdateInProgress, nil + case "RemoteStartActive": + return Field_RemoteStartActive, nil + case "SemiCruiseSpeedLimitMph": + return Field_SemiCruiseSpeedLimitMph, nil } return Field(0), fmt.Errorf("protomodel: unknown Field name %q", s) } @@ -1605,6 +1655,16 @@ var Signals = []SignalMeta{ {Field: "Deprecated_3", ProtoEnumNum: 257, Category: "metadata", ValueKind: ValueKindString, EnumTypeName: "", EnumStringPrefix: "", IsCompound: false, CompoundKind: CompoundKindNone, UnitKind: UnitKindNone, IsSettingUnit: false}, {Field: "MilesSinceReset", ProtoEnumNum: 258, Category: "safety_security", ValueKind: ValueKindFloat, EnumTypeName: "", EnumStringPrefix: "", IsCompound: false, CompoundKind: CompoundKindNone, UnitKind: UnitKindDistance, IsSettingUnit: false}, {Field: "SelfDrivingMilesSinceReset", ProtoEnumNum: 259, Category: "safety_security", ValueKind: ValueKindFloat, EnumTypeName: "", EnumStringPrefix: "", IsCompound: false, CompoundKind: CompoundKindNone, UnitKind: UnitKindDistance, IsSettingUnit: false}, + {Field: "GpsAccuracyMeters", ProtoEnumNum: 260, Category: "location", ValueKind: ValueKindFloat, EnumTypeName: "", EnumStringPrefix: "", IsCompound: false, CompoundKind: CompoundKindNone, UnitKind: UnitKindNone, IsSettingUnit: false}, + {Field: "LifetimeEnergyChargedKwh", ProtoEnumNum: 261, Category: "charging", ValueKind: ValueKindFloat, EnumTypeName: "", EnumStringPrefix: "", IsCompound: false, CompoundKind: CompoundKindNone, UnitKind: UnitKindNone, IsSettingUnit: false}, + {Field: "BrickSocMinPercent", ProtoEnumNum: 262, Category: "charging", ValueKind: ValueKindFloat, EnumTypeName: "", EnumStringPrefix: "", IsCompound: false, CompoundKind: CompoundKindNone, UnitKind: UnitKindCharge, IsSettingUnit: false}, + {Field: "NominalFullPackEnergyKwh", ProtoEnumNum: 263, Category: "charging", ValueKind: ValueKindFloat, EnumTypeName: "", EnumStringPrefix: "", IsCompound: false, CompoundKind: CompoundKindNone, UnitKind: UnitKindNone, IsSettingUnit: false}, + {Field: "GradeEstimatePercent", ProtoEnumNum: 264, Category: "driving", ValueKind: ValueKindFloat, EnumTypeName: "", EnumStringPrefix: "", IsCompound: false, CompoundKind: CompoundKindNone, UnitKind: UnitKindNone, IsSettingUnit: false}, + {Field: "MaxSpeedToReachDestinationMph", ProtoEnumNum: 265, Category: "driving", ValueKind: ValueKindFloat, EnumTypeName: "", EnumStringPrefix: "", IsCompound: false, CompoundKind: CompoundKindNone, UnitKind: UnitKindNone, IsSettingUnit: false}, + {Field: "SoftwareUpdateAvailable", ProtoEnumNum: 266, Category: "vehicle_state", ValueKind: ValueKindBool, EnumTypeName: "", EnumStringPrefix: "", IsCompound: false, CompoundKind: CompoundKindNone, UnitKind: UnitKindNone, IsSettingUnit: false}, + {Field: "SoftwareUpdateInProgress", ProtoEnumNum: 267, Category: "vehicle_state", ValueKind: ValueKindBool, EnumTypeName: "", EnumStringPrefix: "", IsCompound: false, CompoundKind: CompoundKindNone, UnitKind: UnitKindNone, IsSettingUnit: false}, + {Field: "RemoteStartActive", ProtoEnumNum: 268, Category: "vehicle_state", ValueKind: ValueKindBool, EnumTypeName: "", EnumStringPrefix: "", IsCompound: false, CompoundKind: CompoundKindNone, UnitKind: UnitKindNone, IsSettingUnit: false}, + {Field: "SemiCruiseSpeedLimitMph", ProtoEnumNum: 269, Category: "driving", ValueKind: ValueKindFloat, EnumTypeName: "", EnumStringPrefix: "", IsCompound: false, CompoundKind: CompoundKindNone, UnitKind: UnitKindNone, IsSettingUnit: false}, } // SignalsByName indexes Signals by canonical proto field name. The pointer diff --git a/internal/tesla/router/routing.yaml b/internal/tesla/router/routing.yaml index 8a4c12e3cb..fe4e75b5ed 100644 --- a/internal/tesla/router/routing.yaml +++ b/internal/tesla/router/routing.yaml @@ -77,6 +77,8 @@ routes: dest: signal_log - field: BrickVoltageMin dest: signal_log + - field: BrickSocMinPercent + dest: signal_log - field: CabinOverheatProtectionMode dest: climate_snapshot column: cabin_overheat_protection_mode @@ -435,6 +437,8 @@ routes: - field: Gear dest: drive_telemetry column: gear + - field: GpsAccuracyMeters + dest: signal_log - field: GpsHeading dest: positions column: heading_deg @@ -506,6 +510,10 @@ routes: - field: LateralAcceleration dest: drive_telemetry column: lateral_acceleration_mps2 + - field: GradeEstimatePercent + dest: signal_log + - field: LifetimeEnergyChargedKwh + dest: signal_log - field: LifetimeEnergyGainedRegen dest: drive_telemetry column: lifetime_energy_gained_regen_wh @@ -602,6 +610,8 @@ routes: # to meters before the SI value lands in signal_log. - field: MilesSinceReset dest: signal_log + - field: MaxSpeedToReachDestinationMph + dest: signal_log - field: MilesToArrival dest: signal_log - field: MinutesToArrival @@ -610,6 +620,8 @@ routes: dest: signal_log - field: ModuleTempMin dest: signal_log + - field: NominalFullPackEnergyKwh + dest: signal_log - field: NotEnoughPowerToHeat dest: signal_log - field: NumBrickVoltageMax @@ -706,6 +718,8 @@ routes: # per-vehicle feature flag, and RoofColor is paint trim. Each # carries the change feed in signal_log; downstream readers fetch # the most recent value back from there. No hot column exists. + - field: RemoteStartActive + dest: signal_log - field: RemoteStartEnabled dest: signal_log - field: RightHandDrive @@ -754,6 +768,8 @@ routes: # UnitKindDistance). Same routing rationale as MilesSinceReset — # the wire value is fixed in miles, normalize.toSI bypasses # vehicle_unit_history, and the SI value lands in signal_log. + - field: SemiCruiseSpeedLimitMph + dest: signal_log - field: SelfDrivingMilesSinceReset dest: signal_log - field: SemitruckPassengerSeatFoldPosition @@ -840,10 +856,14 @@ routes: dest: signal_log # SoftwareUpdate* atomics (vehicle_state). Update-progress UI feeds # off the signal_log change stream; no hot column exists. + - field: SoftwareUpdateAvailable + dest: signal_log - field: SoftwareUpdateDownloadPercentComplete dest: signal_log - field: SoftwareUpdateExpectedDurationMinutes dest: signal_log + - field: SoftwareUpdateInProgress + dest: signal_log - field: SoftwareUpdateInstallationPercentComplete dest: signal_log - field: SoftwareUpdateScheduledStartTime diff --git a/internal/tesla/router/writers/signal_log_writer_test.go b/internal/tesla/router/writers/signal_log_writer_test.go index 6ffc86207f..57f6fcb578 100644 --- a/internal/tesla/router/writers/signal_log_writer_test.go +++ b/internal/tesla/router/writers/signal_log_writer_test.go @@ -83,7 +83,7 @@ func assertSignalLogCallShape(t *testing.T, call recordedCall) { // in the LOCKED set (string/bool/int32/int64/float32/float64/time.Time/ // proto enum), the bump-only edit is trivial. func TestSignalLogWriter_RoutingYAMLCoverage(t *testing.T) { - const wantSignalLogRoutes = 144 + const wantSignalLogRoutes = 154 entries, err := router.LoadMap() if err != nil { diff --git a/internal/tesla/units/conversions.go b/internal/tesla/units/conversions.go index 5f76c382e3..60d891f74b 100644 --- a/internal/tesla/units/conversions.go +++ b/internal/tesla/units/conversions.go @@ -184,10 +184,12 @@ func IsFixedMileDistanceField(field string) bool { // AC/DCChargingEnergyIn in kWh and AC/DCChargingPower in kW: // https://developer.tesla.com/docs/fleet-api/fleet-telemetry/available-data var fixedKiloToBaseFields = map[string]bool{ - "ACChargingEnergyIn": true, - "DCChargingEnergyIn": true, - "ACChargingPower": true, - "DCChargingPower": true, + "ACChargingEnergyIn": true, + "DCChargingEnergyIn": true, + "ACChargingPower": true, + "DCChargingPower": true, + "LifetimeEnergyChargedKwh": true, + "NominalFullPackEnergyKwh": true, } // IsFixedKiloToBaseField reports whether a Tesla field has a fixed kWh/kW @@ -195,3 +197,17 @@ var fixedKiloToBaseFields = map[string]bool{ func IsFixedKiloToBaseField(field string) bool { return fixedKiloToBaseFields[field] } + +// fixedMphSpeedFields lists linear-velocity proto fields whose wire +// value is ALWAYS miles per hour (name-encoded), independent of +// SettingDistanceUnit. Canonical SI is metres per second. +var fixedMphSpeedFields = map[string]bool{ + "MaxSpeedToReachDestinationMph": true, + "SemiCruiseSpeedLimitMph": true, +} + +// IsFixedMphSpeedField reports whether a Tesla speed field is always +// mph on the wire and must convert to m/s without unit history. +func IsFixedMphSpeedField(field string) bool { + return fixedMphSpeedFields[field] +} diff --git a/internal/tesla/units/units.go b/internal/tesla/units/units.go index a552403dd6..1870690089 100644 --- a/internal/tesla/units/units.go +++ b/internal/tesla/units/units.go @@ -85,6 +85,10 @@ func ToSI(field string, raw float64, active ActiveUnit) (float64, error) { return raw * 1000.0, nil } + if IsFixedMphSpeedField(field) { + return raw * 0.44704, nil + } + if meta.UnitKind == protomodel.UnitKindNone { return 0, fmt.Errorf("%w: %q", ErrUnsupportedField, field) } diff --git a/internal/tesla/units/units_test.go b/internal/tesla/units/units_test.go index 6b8788d612..46a6816d54 100644 --- a/internal/tesla/units/units_test.go +++ b/internal/tesla/units/units_test.go @@ -74,6 +74,10 @@ func TestToSI(t *testing.T) { {name: "DCChargingEnergyIn/kWh_to_Wh", field: "DCChargingEnergyIn", raw: 42.5, active: ActiveUnitMiles, want: 42500, eps: epsTight}, {name: "ACChargingPower/kW_to_W", field: "ACChargingPower", raw: 7.2, active: "", want: 7200, eps: epsTight}, {name: "DCChargingPower/kW_to_W", field: "DCChargingPower", raw: 250, active: ActiveUnitPSI, want: 250000, eps: epsTight}, + {name: "LifetimeEnergyChargedKwh/kWh_to_Wh", field: "LifetimeEnergyChargedKwh", raw: 12.5, active: "", want: 12500, eps: epsTight}, + {name: "NominalFullPackEnergyKwh/kWh_to_Wh", field: "NominalFullPackEnergyKwh", raw: 100, active: ActiveUnitMiles, want: 100000, eps: epsTight}, + {name: "MaxSpeedToReachDestinationMph/mph_to_mps", field: "MaxSpeedToReachDestinationMph", raw: 65, active: "", want: 29.0576, eps: epsLoose}, + {name: "SemiCruiseSpeedLimitMph/mph_to_mps", field: "SemiCruiseSpeedLimitMph", raw: 55, active: "", want: 24.5872, eps: epsLoose}, // Temperature fields. {name: "DiHeatsinkTR/F", field: "DiHeatsinkTR", raw: 32, active: ActiveUnitFahrenheit, want: 0, eps: epsTight}, diff --git a/web/src/features/maps/pages/MapOverviewPage.test.tsx b/web/src/features/maps/pages/MapOverviewPage.test.tsx index d31300c373..b9698345de 100644 --- a/web/src/features/maps/pages/MapOverviewPage.test.tsx +++ b/web/src/features/maps/pages/MapOverviewPage.test.tsx @@ -560,7 +560,7 @@ describe('MapOverviewPage — accessibility', () => { const status = await screen.findByRole('region', { name: 'Vehicle status' }) expect(status).toBeInTheDocument() const map = screen.getByRole('region', { name: 'Live location map' }) - expect(within(map).getByTestId('map-container')).toBeInTheDocument() + expect(await within(map).findByTestId('map-container')).toBeInTheDocument() // The vehicle scope picker is a labelled combobox. expect(screen.getByRole('combobox', { name: 'Select vehicle' })).toBeInTheDocument() diff --git a/web/src/features/vehicles/components/telemetry-panels/LiveTelemetryPanels.tsx b/web/src/features/vehicles/components/telemetry-panels/LiveTelemetryPanels.tsx index 9287bb8627..d4eb7ce890 100644 --- a/web/src/features/vehicles/components/telemetry-panels/LiveTelemetryPanels.tsx +++ b/web/src/features/vehicles/components/telemetry-panels/LiveTelemetryPanels.tsx @@ -28,6 +28,7 @@ interface LiveTelemetryProps { live: Record sseConnected: boolean remoteStartEnabled?: boolean | null + remoteStartActive?: boolean | null } // Stable empty-object fallback for the `live` signal map. VehicleStatePanel @@ -47,6 +48,7 @@ export function LiveTelemetryPanels({ live, sseConnected, remoteStartEnabled, + remoteStartActive, }: LiveTelemetryProps) { const { t } = useTranslation() @@ -75,7 +77,11 @@ export function LiveTelemetryPanels({ - + diff --git a/web/src/features/vehicles/components/telemetry-panels/SecurityPanel.test.tsx b/web/src/features/vehicles/components/telemetry-panels/SecurityPanel.test.tsx index 89551b6570..1bfca67097 100644 --- a/web/src/features/vehicles/components/telemetry-panels/SecurityPanel.test.tsx +++ b/web/src/features/vehicles/components/telemetry-panels/SecurityPanel.test.tsx @@ -178,6 +178,7 @@ describe('SecurityPanel — user presence tri-state', () => { , ) @@ -217,11 +218,23 @@ describe('SecurityPanel — remote start', () => { }) it('shows an em-dash when remoteStartEnabled is null', () => { - render() + render( + , + ) - // makeSecurity() defaults leave only the remote-start value as an em-dash. expect(screen.getByText('—')).toBeInTheDocument() }) + + it('shows Active when remoteStartActive is true', () => { + render() + + expect(screen.getByText('Remote Start Active')).toBeInTheDocument() + expect(screen.getByText('Active')).toBeInTheDocument() + }) }) describe('SecurityPanel — empty & partial states', () => { diff --git a/web/src/features/vehicles/components/telemetry-panels/SecurityPanel.tsx b/web/src/features/vehicles/components/telemetry-panels/SecurityPanel.tsx index 7a8af172e5..8e99b1ce44 100644 --- a/web/src/features/vehicles/components/telemetry-panels/SecurityPanel.tsx +++ b/web/src/features/vehicles/components/telemetry-panels/SecurityPanel.tsx @@ -8,12 +8,13 @@ import type { SecurityEvent } from '@/api/types' interface SecurityPanelProps { securityData: SecurityEvent | null | undefined remoteStartEnabled?: boolean | null + remoteStartActive?: boolean | null } -export function SecurityPanel({ securityData, remoteStartEnabled }: SecurityPanelProps) { +export function SecurityPanel({ securityData, remoteStartEnabled, remoteStartActive }: SecurityPanelProps) { const { t } = useTranslation() - const hasData = securityData != null || remoteStartEnabled != null + const hasData = securityData != null || remoteStartEnabled != null || remoteStartActive != null return ( @@ -165,6 +166,27 @@ export function SecurityPanel({ securityData, remoteStartEnabled }: SecurityPane : t('common.disabled', 'Disabled')} +
+ + + + {remoteStartActive == null + ? '—' + : remoteStartActive + ? t('common.active', 'Active') + : t('common.off', 'Off')} + +
) : ( diff --git a/web/src/features/vehicles/components/telemetry-panels/VehicleStatePanel.test.tsx b/web/src/features/vehicles/components/telemetry-panels/VehicleStatePanel.test.tsx index 63ce65a881..448eb9e12b 100644 --- a/web/src/features/vehicles/components/telemetry-panels/VehicleStatePanel.test.tsx +++ b/web/src/features/vehicles/components/telemetry-panels/VehicleStatePanel.test.tsx @@ -74,6 +74,11 @@ const ALL_LABELS = [ 'Speed Limit', 'Center Display', 'HomeLink Devices', + 'Software Update', + 'Remote Start Active', + 'GPS Accuracy', + 'Grade', + 'Max speed to destination', ]; beforeEach(() => { diff --git a/web/src/features/vehicles/components/telemetry-panels/VehicleStatePanel.tsx b/web/src/features/vehicles/components/telemetry-panels/VehicleStatePanel.tsx index a33e3d10e0..54346f0f16 100644 --- a/web/src/features/vehicles/components/telemetry-panels/VehicleStatePanel.tsx +++ b/web/src/features/vehicles/components/telemetry-panels/VehicleStatePanel.tsx @@ -174,6 +174,48 @@ export function VehicleStatePanel({ live, sseConnected }: VehicleStatePanelProps label={t('telemetry.homelinkDevices', 'HomeLink Devices')} value={formatCount(live.homelinkDeviceCount)} /> + +
+ + + + + +
) diff --git a/web/src/hooks/useVehicleLive.ts b/web/src/hooks/useVehicleLive.ts index ed38060029..6dc259d22f 100644 --- a/web/src/hooks/useVehicleLive.ts +++ b/web/src/hooks/useVehicleLive.ts @@ -135,6 +135,7 @@ export interface VehicleLiveState { europeVehicle: boolean rightHandDrive: boolean remoteStartEnabled: boolean + remoteStartActive: boolean offroadLightbar: boolean // Navigation @@ -148,8 +149,15 @@ export interface VehicleLiveState { locatedAtWork: boolean locatedAtFavorite: boolean gpsState: string + gpsAccuracyM: number originLatitude: number originLongitude: number + gradeEstimatePct: number + maxSpeedToDestinationMps: number + semiCruiseSpeedLimitMps: number + lifetimeEnergyChargedWh: number + brickSocMinPct: number + nominalFullPackEnergyWh: number // Software Update (live progress) swUpdateVersion: string @@ -157,6 +165,8 @@ export interface VehicleLiveState { swUpdateInstallPct: number swUpdateExpectedMin: number swUpdateScheduledStart: string + swUpdateAvailable: boolean + swUpdateInProgress: boolean // User Preferences (from car) setting24HourTime: boolean @@ -201,13 +211,16 @@ const EMPTY_STATE: VehicleLiveState = { isolationResistance: 0, vehicleName: '', carType: '', version: '', wheelType: '', exteriorColor: '', trim: '', roofColor: '', efficiencyPackage: '', rearSeatHeaters: '', sunroofInstalled: '', - europeVehicle: false, rightHandDrive: false, remoteStartEnabled: false, offroadLightbar: false, + europeVehicle: false, rightHandDrive: false, remoteStartEnabled: false, remoteStartActive: false, offroadLightbar: false, destinationName: '', destinationLatitude: 0, destinationLongitude: 0, distanceToArrival: 0, minutesToArrival: 0, routeLine: '', locatedAtHome: false, locatedAtWork: false, locatedAtFavorite: false, - gpsState: '', originLatitude: 0, originLongitude: 0, + gpsState: '', gpsAccuracyM: 0, originLatitude: 0, originLongitude: 0, + gradeEstimatePct: 0, maxSpeedToDestinationMps: 0, semiCruiseSpeedLimitMps: 0, + lifetimeEnergyChargedWh: 0, brickSocMinPct: 0, nominalFullPackEnergyWh: 0, swUpdateVersion: '', swUpdateDownloadPct: 0, swUpdateInstallPct: 0, swUpdateExpectedMin: 0, swUpdateScheduledStart: '', + swUpdateAvailable: false, swUpdateInProgress: false, setting24HourTime: false, settingChargeUnit: '', settingDistanceUnit: '', settingTemperatureUnit: '', settingTirePressureUnit: '', lastUpdated: null, signalCount: 0, @@ -372,6 +385,7 @@ function parseSignals(raw: Record): Partial { if (raw['EuropeVehicle'] != null) s.europeVehicle = bool('EuropeVehicle') if (raw['RightHandDrive'] != null) s.rightHandDrive = bool('RightHandDrive') if (raw['RemoteStartEnabled'] != null) s.remoteStartEnabled = bool('RemoteStartEnabled') + if (raw['RemoteStartActive'] != null) s.remoteStartActive = bool('RemoteStartActive') if (raw['OffroadLightbarPresent'] != null) s.offroadLightbar = bool('OffroadLightbarPresent') // Navigation @@ -388,6 +402,13 @@ function parseSignals(raw: Record): Partial { if (raw['LocatedAtWork'] != null) s.locatedAtWork = bool('LocatedAtWork') if (raw['LocatedAtFavorite'] != null) s.locatedAtFavorite = bool('LocatedAtFavorite') if (raw['GpsState'] != null) s.gpsState = str('GpsState') + if (raw['GpsAccuracyMeters'] != null) s.gpsAccuracyM = n('GpsAccuracyMeters') + if (raw['GradeEstimatePercent'] != null) s.gradeEstimatePct = n('GradeEstimatePercent') + if (raw['MaxSpeedToReachDestinationMph'] != null) s.maxSpeedToDestinationMps = n('MaxSpeedToReachDestinationMph') + if (raw['SemiCruiseSpeedLimitMph'] != null) s.semiCruiseSpeedLimitMps = n('SemiCruiseSpeedLimitMph') + if (raw['LifetimeEnergyChargedKwh'] != null) s.lifetimeEnergyChargedWh = n('LifetimeEnergyChargedKwh') + if (raw['BrickSocMinPercent'] != null) s.brickSocMinPct = n('BrickSocMinPercent') + if (raw['NominalFullPackEnergyKwh'] != null) s.nominalFullPackEnergyWh = n('NominalFullPackEnergyKwh') if (raw['OriginLocation'] != null && typeof raw['OriginLocation'] === 'object') { const orig = raw['OriginLocation'] as Record if (orig['latitude'] != null) s.originLatitude = orig['latitude'] as number @@ -400,6 +421,8 @@ function parseSignals(raw: Record): Partial { if (raw['SoftwareUpdateInstallationPercentComplete'] != null) s.swUpdateInstallPct = n('SoftwareUpdateInstallationPercentComplete') if (raw['SoftwareUpdateExpectedDurationMinutes'] != null) s.swUpdateExpectedMin = n('SoftwareUpdateExpectedDurationMinutes') if (raw['SoftwareUpdateScheduledStartTime'] != null) s.swUpdateScheduledStart = str('SoftwareUpdateScheduledStartTime') + if (raw['SoftwareUpdateAvailable'] != null) s.swUpdateAvailable = bool('SoftwareUpdateAvailable') + if (raw['SoftwareUpdateInProgress'] != null) s.swUpdateInProgress = bool('SoftwareUpdateInProgress') // User Preferences if (raw['Setting24HourTime'] != null) s.setting24HourTime = bool('Setting24HourTime') diff --git a/web/src/lib/signalCatalog.ts b/web/src/lib/signalCatalog.ts index ddd569a29a..cdf3a2bb3c 100644 --- a/web/src/lib/signalCatalog.ts +++ b/web/src/lib/signalCatalog.ts @@ -22,6 +22,7 @@ const catalog: SignalMeta[] = [ { name: 'BMSState', category: 'Battery', type: 'string', description: 'Battery Management System state', enumValues: ['Standby', 'Drive', 'Support', 'Charge', 'Fault'] }, { name: 'BatteryHeaterOn', category: 'Battery', type: 'boolean', description: 'Battery heater active' }, { name: 'BmsFullchargecomplete', category: 'Charging', type: 'boolean', description: 'BMS reports full charge complete' }, + { name: 'BrickSocMinPercent', category: 'Battery', type: 'number', unit: '%', description: 'Minimum brick state of charge' }, { name: 'BrickVoltageMax', category: 'Battery', type: 'number', unit: 'V', description: 'Maximum brick voltage' }, { name: 'BrickVoltageMin', category: 'Battery', type: 'number', unit: 'V', description: 'Minimum brick voltage' }, { name: 'ChargeAmps', category: 'Charging', type: 'number', unit: 'A', description: 'Current charging amperage' }, @@ -49,9 +50,11 @@ const catalog: SignalMeta[] = [ { name: 'FastChargerPresent', category: 'Charging', type: 'boolean', description: 'Fast charger detected' }, { name: 'FastChargerType', category: 'Charging', type: 'string', description: 'Type of fast charger', enumValues: ['Supercharger', 'CCS', 'CHAdeMO', 'Other', 'None'] }, { name: 'IdealBatteryRange', category: 'Battery', type: 'number', unit: 'mi', description: 'Ideal battery range' }, + { name: 'LifetimeEnergyChargedKwh', category: 'Battery', type: 'number', unit: 'Wh', description: 'Lifetime energy charged (stored as watt-hours)' }, { name: 'LifetimeEnergyUsed', category: 'Battery', type: 'number', unit: 'kWh', description: 'Total lifetime energy used' }, { name: 'ModuleTempMax', category: 'Battery', type: 'number', unit: '°C', description: 'Maximum battery module temperature' }, { name: 'ModuleTempMin', category: 'Battery', type: 'number', unit: '°C', description: 'Minimum battery module temperature' }, + { name: 'NominalFullPackEnergyKwh', category: 'Battery', type: 'number', unit: 'Wh', description: 'Nominal full pack energy (stored as watt-hours)' }, { name: 'NotEnoughPowerToHeat', category: 'Battery', type: 'boolean', description: 'Not enough power to heat battery' }, { name: 'NumBrickVoltageMax', category: 'Battery', type: 'number', description: 'Brick number with maximum voltage' }, { name: 'NumBrickVoltageMin', category: 'Battery', type: 'number', description: 'Brick number with minimum voltage' }, @@ -111,6 +114,9 @@ const catalog: SignalMeta[] = [ { name: 'BrakePedal', category: 'Driving', type: 'boolean', description: 'Brake pedal pressed' }, { name: 'BrakePedalPos', category: 'Driving', type: 'number', unit: '%', description: 'Brake pedal position' }, { name: 'CruiseSetSpeed', category: 'Driving', type: 'number', unit: 'km/h', description: 'Cruise control set speed' }, + { name: 'GradeEstimatePercent', category: 'Driving', type: 'number', unit: '%', description: 'Estimated road grade' }, + { name: 'MaxSpeedToReachDestinationMph', category: 'Navigation', type: 'number', unit: 'm/s', description: 'Max speed to still arrive on time (stored as metres per second)' }, + { name: 'SemiCruiseSpeedLimitMph', category: 'Driving', type: 'number', unit: 'm/s', description: 'Semi-truck cruise speed limit (stored as metres per second)' }, { name: 'DriveRail', category: 'Driving', type: 'boolean', description: 'Drive rail active' }, { name: 'Gear', category: 'Driving', type: 'string', description: 'Current gear', enumValues: ['P', 'R', 'N', 'D'] }, { name: 'LateralAcceleration', category: 'Driving', type: 'number', unit: 'g', description: 'Lateral acceleration' }, @@ -161,6 +167,7 @@ const catalog: SignalMeta[] = [ // ── Location ────────────────────────────────────────────────────────── { name: 'DestinationLocation', category: 'Location', type: 'string', description: 'Navigation destination coordinates' }, { name: 'DestinationName', category: 'Navigation', type: 'string', description: 'Navigation destination name' }, + { name: 'GpsAccuracyMeters', category: 'Location', type: 'number', unit: 'm', description: 'GPS accuracy radius' }, { name: 'GpsHeading', category: 'Location', type: 'number', unit: '°', description: 'GPS heading in degrees' }, { name: 'GpsState', category: 'Location', type: 'string', description: 'GPS fix state. Polymorphic: Tesla emits "true"/"false" or "GpsLocked"; legacy data uses "GPSValid"/"GPSInvalid"; canonical enum is NoFix/Fix2D/Fix3D. Use normalizeGpsState() before display.', enumValues: ['NoFix', 'Fix2D', 'Fix3D', 'GPSValid', 'GPSInvalid', 'GpsLocked', 'true', 'false'] }, { name: 'LocatedAtFavorite', category: 'Location', type: 'boolean', description: 'Vehicle at a favorite location' }, @@ -238,8 +245,10 @@ const catalog: SignalMeta[] = [ { name: 'SpeedLimitMode', category: 'Safety', type: 'boolean', description: 'Speed limit mode active' }, // ── Software ────────────────────────────────────────────────────────── + { name: 'SoftwareUpdateAvailable', category: 'Software', type: 'boolean', description: 'Software update available' }, { name: 'SoftwareUpdateDownloadPercentComplete', category: 'Software', type: 'number', unit: '%', description: 'Software update download progress' }, { name: 'SoftwareUpdateExpectedDurationMinutes', category: 'Software', type: 'number', unit: 'min', description: 'Expected software update duration' }, + { name: 'SoftwareUpdateInProgress', category: 'Software', type: 'boolean', description: 'Software update currently installing' }, { name: 'SoftwareUpdateInstallationPercentComplete', category: 'Software', type: 'number', unit: '%', description: 'Software update installation progress' }, { name: 'SoftwareUpdateScheduledStartTime', category: 'Software', type: 'string', description: 'Scheduled software update start time' }, { name: 'SoftwareUpdateVersion', category: 'Software', type: 'string', description: 'Pending software update version' }, @@ -255,6 +264,7 @@ const catalog: SignalMeta[] = [ { name: 'ExteriorColor', category: 'Vehicle Config', type: 'string', description: 'Exterior paint color' }, { name: 'OffroadLightbarPresent', category: 'Vehicle Config', type: 'boolean', description: 'Offroad lightbar installed' }, { name: 'RearSeatHeaters', category: 'Vehicle Config', type: 'number', description: 'Number of rear seat heaters' }, + { name: 'RemoteStartActive', category: 'Security', type: 'boolean', description: 'Remote start currently active' }, { name: 'RemoteStartEnabled', category: 'Security', type: 'boolean', description: 'Remote start enabled' }, { name: 'RightHandDrive', category: 'Vehicle Config', type: 'boolean', description: 'Right-hand drive vehicle' }, { name: 'RoofColor', category: 'Vehicle Config', type: 'string', description: 'Roof color' },