diff --git a/dashboard/src/components/widgets/p987/live/EcuDebugWidget.tsx b/dashboard/src/components/widgets/p987/live/EcuDebugWidget.tsx
new file mode 100644
index 00000000..789d8e2b
--- /dev/null
+++ b/dashboard/src/components/widgets/p987/live/EcuDebugWidget.tsx
@@ -0,0 +1,49 @@
+import LiveWidget from "@/components/widgets/LiveWidget";
+
+interface EcuDebugWidgetProps {
+ vehicle_id: string;
+ showDeltaBanner?: boolean;
+ title: string;
+ signals: string[];
+}
+
+// Shared by every stock-CAN ECU on the 987. gr26 has one debug widget per
+// node written out longhand; the 987 has seven ECUs decoded from the DBC,
+// so they share this and pass their own signal list.
+export default function EcuDebugWidget({
+ vehicle_id,
+ showDeltaBanner = false,
+ title,
+ signals,
+}: EcuDebugWidgetProps) {
+ const sorted = [...signals].sort();
+
+ return (
+
+ {(_, currentSignals) => (
+
+
{title}
+
+ {sorted.map((signal) => (
+
+
+ {signal.replace(/^pcan_/, "")}
+
+
+ {currentSignals.get(signal)?.value ?? 0}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/dashboard/src/components/widgets/p987/live/TcmResourceWidget.tsx b/dashboard/src/components/widgets/p987/live/TcmResourceWidget.tsx
new file mode 100644
index 00000000..a16fcbff
--- /dev/null
+++ b/dashboard/src/components/widgets/p987/live/TcmResourceWidget.tsx
@@ -0,0 +1,203 @@
+import LiveWidget from "@/components/widgets/LiveWidget";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Progress } from "@/components/ui/progress";
+import { Cpu, HardDrive, MemoryStick, Thermometer } from "lucide-react";
+
+interface TcmResourceWidgetProps {
+ vehicle_id: string;
+ showDeltaBanner?: boolean;
+}
+
+// The 987's TCM is a Pi Zero 2 W, so this is deliberately not the gr25
+// layout: four cores instead of six, and no GPU or power-rail readings
+// because the board has no sensors for them. The throttle flags take their
+// place — under-voltage is the failure mode this hardware actually has,
+// and it shows up in none of the other metrics.
+export default function TcmResourceWidget({
+ vehicle_id,
+ showDeltaBanner = false,
+}: TcmResourceWidgetProps) {
+ const cores = [0, 1, 2, 3];
+ const signals = [
+ "tcm_cpu_total_util",
+ ...cores.map((n) => `tcm_cpu_${n}_util`),
+ ...cores.map((n) => `tcm_cpu_${n}_freq`),
+ "tcm_cpu_temp",
+ "tcm_ram_total",
+ "tcm_ram_used",
+ "tcm_ram_util",
+ "tcm_disk_total",
+ "tcm_disk_used",
+ "tcm_disk_util",
+ "tcm_undervoltage",
+ "tcm_undervoltage_since_boot",
+ "tcm_thermal_throttled",
+ "tcm_thermal_throttled_since_boot",
+ ];
+
+ const utilizationColor = (value: number) => {
+ if (value < 50) return "bg-green-500";
+ if (value < 80) return "bg-yellow-500";
+ return "bg-red-500";
+ };
+
+ const tempColor = (value: number) => {
+ // The Pi soft-throttles at 80C and hard-throttles at 85C.
+ if (value < 60) return "text-green-500";
+ if (value < 80) return "text-yellow-500";
+ return "text-red-500";
+ };
+
+ return (
+
+ {(_, currentSignals) => {
+ const value = (name: string) => currentSignals.get(name)?.value ?? 0;
+ const flag = (name: string) => value(name) === 1;
+
+ return (
+
+
TCM Resources
+
+
+
+
+ CPU Utilization
+
+
+
+
+
+ {value("tcm_cpu_total_util").toFixed(0)}%
+
+
+
+ {cores.map((n) => (
+
+ core {n}
+
+ {value(`tcm_cpu_${n}_util`).toFixed(0)}% ·{" "}
+ {value(`tcm_cpu_${n}_freq`).toFixed(0)} MHz
+
+
+ ))}
+
+
+
+
+
+
+ Memory
+
+
+
+
+ {value("tcm_ram_util").toFixed(0)}%
+
+
+
+ {value("tcm_ram_used").toFixed(0)} /{" "}
+ {value("tcm_ram_total").toFixed(0)} MB
+
+
+
+
+
+
+ Disk
+
+
+
+
+ {value("tcm_disk_util").toFixed(0)}%
+
+
+
+ {value("tcm_disk_used").toFixed(0)} /{" "}
+ {value("tcm_disk_total").toFixed(0)} MB
+
+
+
+
+
+
+
+ Temperature & Throttling
+
+
+
+
+
+ {value("tcm_cpu_temp").toFixed(0)}°C
+
+
+
+
+
+
+
+
+
+ );
+ }}
+
+ );
+}
+
+// A since-boot flag that is set while the live flag is clear means the
+// event happened earlier in this power cycle — worth surfacing separately
+// rather than collapsing both into one indicator.
+function ThrottleRow({
+ label,
+ now,
+ sinceBoot,
+}: {
+ label: string;
+ now: boolean;
+ sinceBoot: boolean;
+}) {
+ const color = now
+ ? "text-red-500"
+ : sinceBoot
+ ? "text-yellow-500"
+ : "text-muted-foreground";
+ const state = now ? "active" : sinceBoot ? "seen since boot" : "clear";
+ return (
+
+ {label}
+ {state}
+
+ );
+}
diff --git a/dashboard/src/components/widgets/p987/live/TcmStatusWidget.tsx b/dashboard/src/components/widgets/p987/live/TcmStatusWidget.tsx
new file mode 100644
index 00000000..599d84fa
--- /dev/null
+++ b/dashboard/src/components/widgets/p987/live/TcmStatusWidget.tsx
@@ -0,0 +1,65 @@
+import LiveWidget from "@/components/widgets/LiveWidget";
+
+interface TcmStatusWidgetProps {
+ vehicle_id: string;
+ showDeltaBanner?: boolean;
+}
+
+// 0x200, published every 5s. Each connectivity bit is its own signal, so
+// this is a straight read rather than any bit-twiddling.
+export default function TcmStatusWidget({
+ vehicle_id,
+ showDeltaBanner = false,
+}: TcmStatusWidgetProps) {
+ const flags = [
+ { signal: "tcm_connection_ok", label: "Internet" },
+ { signal: "tcm_mqtt_ok", label: "Cloud broker" },
+ { signal: "tcm_mapache_ok", label: "Mapache" },
+ { signal: "tcm_clock_ok", label: "Clock synced" },
+ ];
+ const signals = [...flags.map((f) => f.signal), "tcm_mapache_ping"];
+
+ return (
+
+ {(_, currentSignals) => (
+
+
TCM Status
+
+ {flags.map((f) => {
+ const ok = (currentSignals.get(f.signal)?.value ?? 0) === 1;
+ return (
+
+ {f.label}
+
+ {ok ? "up" : "down"}
+
+
+ );
+ })}
+
+ Round trip
+
+ {(currentSignals.get("tcm_mapache_ping")?.value ?? 0).toFixed(
+ 0,
+ )}{" "}
+ ms
+
+
+
+
+ )}
+
+ );
+}
diff --git a/dashboard/src/components/widgets/p987/live/signals.ts b/dashboard/src/components/widgets/p987/live/signals.ts
new file mode 100644
index 00000000..9f7afab2
--- /dev/null
+++ b/dashboard/src/components/widgets/p987/live/signals.ts
@@ -0,0 +1,101 @@
+// Signal names the p987 ingest publishes, grouped by the ECU that sends
+// them. Names are `_`: the bus segment comes from the
+// relay's CAN_INTERFACES (`can0:pcan`), so these assume the powertrain bus
+// is labelled `pcan`. Relabel the interface and these need to follow.
+const bus = "pcan";
+const on = (...names: string[]) => names.map((n) => `${bus}_${n}`);
+
+// DME — engine management. The full DBC set is ~100 signals across eight
+// frames; this is the subset that describes what the engine is doing
+// rather than counters, checksums and software revisions.
+export const dmeSignals = on(
+ "DME_RPM",
+ "DME_EngineTorque",
+ "DME_DriverTrq",
+ "DME_EngineRunning",
+ "DME_AccelPedalAngle",
+ "DME_APP",
+ "DME_CoolantTemp",
+ "DME_OilTemp",
+ "DME_OilPressure",
+ "DME_EngineCompTemp",
+ "DME_BoostPressure",
+ "DME_AmbientPressure",
+ "DME_Lambda_Value",
+ "DME_Lambda_Status",
+ "DME_EngagedGear",
+ "DME_IdleSpeedTarget",
+ "DME_FuelConsumption1",
+ "DME_FuelConsumption2",
+ "DME_Odometer",
+ "DME_Interventions",
+ "DME_TorqueLoss",
+ "DME_ReducedPower",
+ "DME_Overboost",
+ "DME_CEL_Steady",
+ "DME_CEL_Flashing",
+ "DME_FuelReserve",
+ "DME_OilPressureAlert",
+ "DME_OilTempSensFault",
+ "DME_ChargingAlert",
+ "DME_EngCompFanAlert",
+ "DME_RadFanSpeedReq",
+ "Outside_Temp",
+ "Sport_Mode",
+);
+
+// PSM — stability control, and the only source of wheel speeds.
+export const psmSignals = on(
+ "PSM_WheelSpeedFL",
+ "PSM_WheelSpeedFR",
+ "PSM_WheelSpeedRL",
+ "PSM_WheelSpeedRR",
+ "Vref",
+ "PSM_BrakePressure",
+ "PSM_FootBrake",
+ "PSM_HandBrake",
+ "Yaw_Rate",
+ "Yaw_Rate_Sign",
+ "PSM_LateralAccel",
+ "Longitudinal_Accel",
+ "ABS_Status",
+ "ABS_Error",
+ "ESP_Control",
+ "ESP_Intervention",
+ "ESP_Error",
+ "ESP_Diag_Mode",
+ "PSM_Disabled",
+ "ASR_Requirement",
+ "ASR_Switching",
+ "MSR_Requirement",
+ "EBV_Error",
+ "Brake_Intervention",
+ "Brake_Fluid_Switch",
+ "Engagement_Torque",
+);
+
+// SCCM — steering column. Angle and rate are magnitude-only, each with a
+// separate sign bit, so both are shown rather than combined here.
+export const sccmSignals = on(
+ "SCCM_SteeringAngle",
+ "SCCM_SteeringAngleSign",
+ "SCCM_SteeringAngleRate",
+ "SCCM_SteeringAngleRateSign",
+ "SCCM_CruiseAvailable",
+ "SCCM_CruiseEnable",
+ "SCCM_CruiseUp",
+ "SCCM_CruiseDown",
+ "SCCM_CruiseTowards",
+ "SCCM_CruiseAway",
+);
+
+// PDK — transmission. Absent on manual cars, in which case these stay at
+// their zero defaults.
+export const pdkSignals = on(
+ "PDK_SelectedGear",
+ "PDK_ClutchStatus",
+ "PDK_OilTemp",
+ "PDK_ShiftFork1",
+ "PDK_ShiftFork2",
+ "PDK_ErrorFlags",
+);
diff --git a/dashboard/src/components/widgets/registry.tsx b/dashboard/src/components/widgets/registry.tsx
index d722611c..4da4aa1b 100644
--- a/dashboard/src/components/widgets/registry.tsx
+++ b/dashboard/src/components/widgets/registry.tsx
@@ -42,6 +42,15 @@ import TcmCpuWidget from "@/components/widgets/gr25/live/TcmCpuWidget";
import TcmCpuGraphWidget from "@/components/widgets/gr25/live/TcmCpuGraphWidget";
import Gr25EcuDebugWidget from "@/components/widgets/gr25/live/EcuDebugWidget";
import Gr25InverterDebugWidget from "@/components/widgets/gr25/live/InverterDebugWidget";
+import P987TcmResourceWidget from "@/components/widgets/p987/live/TcmResourceWidget";
+import P987TcmStatusWidget from "@/components/widgets/p987/live/TcmStatusWidget";
+import P987EcuDebugWidget from "@/components/widgets/p987/live/EcuDebugWidget";
+import {
+ dmeSignals,
+ psmSignals,
+ sccmSignals,
+ pdkSignals,
+} from "@/components/widgets/p987/live/signals";
export interface WidgetEntry {
id: string;
@@ -63,9 +72,112 @@ export const getWidgetRegistry = (vehicle_type: string) => {
if (vehicle_type === "gr26") {
return gr26_registry;
}
+ if (vehicle_type === "p987") {
+ return p987_registry;
+ }
return {};
};
+// The 987 is a stock Porsche, so its groups are the car's own ECUs rather
+// than GR-designed nodes. Every debug widget shares one component and
+// passes its own signal list — see p987/live/signals.ts.
+export const p987_registry = {
+ TCM: [
+ {
+ id: "tcm-resources",
+ name: "TCM Resources",
+ description:
+ "Pi Zero 2 W CPU, memory, disk and temperature, plus under-voltage and thermal throttle flags.",
+ component: P987TcmResourceWidget,
+ icon: Activity,
+ span: 6,
+ preview: "/widgets/p987/tcm-resources.png",
+ },
+ {
+ id: "tcm-status",
+ name: "TCM Status",
+ description:
+ "Connectivity bits from 0x200 — internet, cloud broker, Mapache reachability, clock sync — and the round-trip time.",
+ component: P987TcmStatusWidget,
+ icon: Cpu,
+ span: 3,
+ preview: "/widgets/p987/tcm-status.png",
+ },
+ ],
+ DME: [
+ {
+ id: "dme-debug",
+ name: "Engine (DME)",
+ description:
+ "Engine speed, torque, pedal, coolant and oil, boost, lambda and fault flags.",
+ component: (props: any) => (
+
+ ),
+ icon: Gauge,
+ span: 12,
+ preview: "/widgets/p987/dme-debug.png",
+ },
+ ],
+ PSM: [
+ {
+ id: "psm-debug",
+ name: "Stability (PSM)",
+ description:
+ "Wheel speeds, brake pressure, yaw rate, accelerations and ABS/ESP state.",
+ component: (props: any) => (
+
+ ),
+ icon: Activity,
+ span: 12,
+ preview: "/widgets/p987/psm-debug.png",
+ },
+ ],
+ SCCM: [
+ {
+ id: "sccm-debug",
+ name: "Steering (SCCM)",
+ description:
+ "Steering angle and rate with their sign bits, plus cruise control stalk state.",
+ component: (props: any) => (
+
+ ),
+ icon: Crosshair,
+ span: 12,
+ preview: "/widgets/p987/sccm-debug.png",
+ },
+ ],
+ PDK: [
+ {
+ id: "pdk-debug",
+ name: "Transmission (PDK)",
+ description:
+ "Selected gear, clutch status, oil temperature and shift forks. Stays at zero on a manual car.",
+ component: (props: any) => (
+
+ ),
+ icon: Bug,
+ span: 12,
+ preview: "/widgets/p987/pdk-debug.png",
+ },
+ ],
+};
+
export const gr25_registry = {
TCM: [
{
diff --git a/vehicle/model/vehicle_type.go b/vehicle/model/vehicle_type.go
index addbd6e8..4fc80548 100644
--- a/vehicle/model/vehicle_type.go
+++ b/vehicle/model/vehicle_type.go
@@ -11,11 +11,15 @@ const (
GR24 VehicleType = "gr24"
GR25 VehicleType = "gr25"
GR26 VehicleType = "gr26"
+ // P987 is the Porsche 987 Cayman, not an FSAE car. It runs the same
+ // TCM/relay stack but its CAN is stock Porsche, decoded by the p987
+ // service rather than gr26.
+ P987 VehicleType = "p987"
)
// VehicleTypes is the canonical ordered list (newest first), surfaced via the
// API so the frontend's create-vehicle and flag dialogs don't hardcode it.
-var VehicleTypes = []VehicleType{GR26, GR25, GR24}
+var VehicleTypes = []VehicleType{P987, GR26, GR25, GR24}
// VehicleTypeInfo is the API shape for a selectable type.
type VehicleTypeInfo struct {