diff --git a/.github/workflows/pr-format-check.yml b/.github/workflows/pr-format-check.yml index a9c458265..72ba92896 100644 --- a/.github/workflows/pr-format-check.yml +++ b/.github/workflows/pr-format-check.yml @@ -14,4 +14,5 @@ jobs: uses: jidicula/clang-format-action@v4.15.0 with: check-path: '.' + exclude-regex: '.*/platforms/stm32/Core/.*' clang-format-version: '20' \ No newline at end of file diff --git a/scripts/daq/.gitignore b/scripts/daq/.gitignore new file mode 100644 index 000000000..223b83df6 --- /dev/null +++ b/scripts/daq/.gitignore @@ -0,0 +1,4 @@ +generated/ +can_cache.sqlite +logs/ +.env \ No newline at end of file diff --git a/scripts/daq/README.md b/scripts/daq/README.md new file mode 100644 index 000000000..de3f5afca --- /dev/null +++ b/scripts/daq/README.md @@ -0,0 +1,204 @@ +# DAQ — Data Acquisition System + +Real-time CAN telemetry for the MAC Formula Electric racecar. +Data flows from two CAN buses on the Raspberry Pi → InfluxDB → Grafana dashboard. + +## Architecture + +``` +Racecar + can0 (Vehicle bus — FC, LVC, TMS, BMS, GPS) + can1 (Powertrain bus — Inverter 1, Inverter 2) + │ + ▼ + Raspberry Pi 4 + ┌─────────────────────────────────┐ + │ candump -L can0 / can1 │ raw CAN frames + │ logger.py │ decode via veh.dbc + pt.dbc + │ CSV logs (logs/can_log_*.csv) │ full signal archive on SD card + └─────────────────────────────────┘ + │ 4G LTE (SIM card) + ▼ + InfluxDB Cloud (AWS us-east-1, free tier) + │ + ▼ + Dock laptop — Grafana (local, free) + ┌─────────────────────────────────┐ + │ Vehicle Safety row │ + │ Performance row │ + │ Segment Temperatures row │ + └─────────────────────────────────┘ +``` + +The Pi runs **only** logger.py, no InfluxDB or Grafana on the Pi itself. Data is written to InfluxDB Cloud over cellular. Grafana on the dock laptop queries InfluxDB Cloud independently, the Pi and laptop only need their own internet connections, not the same network. + +--- + +## InfluxDB Data Schema + +All telemetry goes into two measurements: + +### `car_data` — key signal values +| Tag | Values | Description | +|---|---|---| +| `signal` | see table below | DBC signal name | +| `inverter` | `inv1`, `inv2` | set only for motor/inverter signals | +| `module` | `0`–`5` | set only for BmsBroadcast segment temps | + +Field: `value` (float) + +### `faults` — active fault events +| Tag | Example values | Description | +|---|---|---| +| `system` | `BMS`, `Motor`, `Inverter`, `IMD`, `APPS`, `CAN`, `Safety`, `Dashboard` | subsystem that raised the fault | +| `fault` | `"Low State of Charge"`, `"No INV1 CAN Comm"` | human-readable description | +| `severity` | `WARNING`, `CRITICAL` | impact level | + +Field: `active` (int, always 1) + +### Signal name reference + +| DBC signal | Source message | CAN ID | Description | +|---|---|---|---| +| `Pack_Inst_Voltage` | Pack_State | 1572 | HV battery voltage (V) | +| `Pack_Current` | Pack_State | 1572 | HV pack current (A) | +| `Pack_SOC` | Pack_SOC | 1573 | State of charge (%) | +| `ActualVelocity` | Inv1/2_ActualValues1 | 643/644 | Motor RPM — tagged `inverter=inv1/inv2` | +| `TempMotor` | Inv1/2_ActualValues2 | 645/646 | Motor temperature (°C) — tagged by inverter | +| `TempInverter` | Inv1/2_ActualValues2 | 645/646 | Inverter temperature (°C) — tagged by inverter | +| `TempIGBT` | Inv1/2_ActualValues2 | 645/646 | IGBT temperature (°C) — tagged by inverter | +| `Speed` | DashCommand | 230 | Vehicle speed (mph from DBC) | +| `HighThermValue` | BmsBroadcast | 0x1839F380 | Segment max temp (°C) — tagged `module=0..5` | +| `LowThermValue` | BmsBroadcast | 0x1839F380 | Segment min temp (°C) — tagged `module=0..5` | +| `AvgThermValue` | BmsBroadcast | 0x1839F380 | Segment avg temp (°C) — tagged `module=0..5` | + +--- + +## Fault Detection + +Faults are **boolean bits** in two CAN messages, not a single fault code signal. + +**FcAlerts (ID 202)** — Front Controller alerts: +| Signal | System | Description | Severity | +|---|---|---|---| +| `AppsImplausible` | APPS | Implausible Pedal Signal | CRITICAL | +| `AccumulatorLowSoc` | BMS | Low State of Charge | WARNING | +| `AccumulatorContactorWrongState` | BMS | Contactor Wrong State | CRITICAL | +| `MotorRetriesExceeded` | Motor | Retries Exceeded | CRITICAL | +| `LeftMotorStartingError` | Motor | Left Motor Start Error | CRITICAL | +| `RightMotorStartingError` | Motor | Right Motor Start Error | CRITICAL | +| `LeftMotorRunningError` | Motor | Left Motor Running Error | CRITICAL | +| `RightMotorRunningError` | Motor | Right Motor Running Error | CRITICAL | +| `DashboardBootTimeout` | Dashboard | Boot Timeout | WARNING | +| `CanTxError` | CAN | TX Error | WARNING | +| `EV47Active` | Safety | EV4.7 Rule Active | CRITICAL | +| `NoInv1Can` | Inverter | No INV1 CAN Comm | CRITICAL | +| `NoInv2Can` | Inverter | No INV2 CAN Comm | CRITICAL | + +**LvStatus (ID 211)** — LV Controller status: +| Signal | System | Description | Severity | +|---|---|---|---| +| `ImdFault` | IMD | Isolation Fault | CRITICAL | +| `BmsFault` | BMS | BMS Fault | CRITICAL | + +When any of these bits is `1`, `logger.py` writes a point to the `faults` measurement with the system, description, and severity. The Grafana Fault Log table shows all faults in the selected time range with colour-coded severity. + +--- + +## Raspberry Pi Setup (Production) + +Run once on a fresh Pi: + +```bash +cd scripts/daq +bash setup.sh +``` + +The script installs `python3`, `can-utils`, configures `can0`/`can1` at 500 kbit/s, and creates a Python virtualenv. + +After running `setup.sh`: + +1. Copy `.env` onto the Pi (it's gitignored — copy it manually or `scp` it over): + ``` + INFLUX_URL=https://us-east-1-1.aws.cloud2.influxdata.com + INFLUX_TOKEN= + INFLUX_ORG=macformula + INFLUX_BUCKET=macfe + ``` + +2. Start logging: + ```bash + source venv/bin/activate + python logger.py + ``` + +--- + +## Linux VM Setup (testing logger.py without a Pi) + +Use this to verify logger.py works before deploying to the Pi. Requires Ubuntu 22.04+ in VirtualBox or VMware — **not WSL2** (WSL2 lacks the `vcan` kernel module). + +```bash +cd scripts/daq +bash setup_vm.sh +``` + +The script installs Python deps and brings up `vcan0`/`vcan1` (virtual CAN interfaces). Uses the cloud InfluxDB credentials already in `.env` — no local InfluxDB needed. + +```bash +# Terminal 1 — run the logger: +source venv/bin/activate +python logger.py --interfaces vcan0 vcan1 + +# Terminal 2 — inject test CAN frames: +cansend vcan0 624#1234567890ABCDEF # Pack_State → writes to InfluxDB +cangen vcan0 -g 50 & # random frames → exercises CSV logging +``` + +Verify: CSV appears in `logs/`, and `car_data` measurement appears in InfluxDB Cloud Data Explorer. + +--- + +## Dock Laptop Setup (Grafana) + +Run once on the laptop you'll use at the dock: + +1. Install Grafana via Docker: + ```bash + docker run -d -p 3000:3000 --name grafana grafana/grafana + ``` + Open `http://localhost:3000` (admin / admin). + +2. Add InfluxDB Cloud as a data source: + - Type: **InfluxDB**, Query language: **Flux** + - URL: value of `INFLUX_URL` from `.env` + - Token: value of `INFLUX_TOKEN` from `.env` + - Organisation: `macformula`, Default bucket: `macfe` + +3. Import `grafana_dashboard.json` via **Dashboards → Import**. + +The dashboard will live-update as the Pi writes data to InfluxDB Cloud. The laptop just needs any internet connection — it doesn't need to be near the Pi. + +--- + +## Adding New Signals + +1. Add the **exact DBC signal name** to `IMPORTANT_SIGNALS` in `logger.py`. +2. If the signal comes from Inv1/Inv2, no extra work needed — the inverter tag is added automatically based on CAN ID. +3. Add a corresponding Flux query panel to `grafana_dashboard.json` (or add it in the Grafana UI and export). + +To add a new fault signal, add an entry to `FAULT_SIGNALS` in `logger.py` with `(system, description, severity)`. The signal must be a boolean bit (0/1) decoded from a message already in `TARGET_IDS`. + +--- + +## Go Bindings (legacy notes) + +The Go files (`main.go`, `can.go`, `heartbeat.go`, `telemetry.go`) implement a Go-based CAN reader for the heartbeat system. The DBC bindings are generated with: + +```bash +go get -u go.einride.tech/can +cd dbc +go run go.einride.tech/can/cmd/cantool generate ./ ../generated/ +``` + +Note: the `Reset` message was removed from DashCommand in the DBC to avoid name conflicts in the generated Go bindings. diff --git a/scripts/daq/can.go b/scripts/daq/can.go new file mode 100644 index 000000000..1f2246651 --- /dev/null +++ b/scripts/daq/can.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "time" + + "github.com/macformula/hil/canlink" + "go.einride.tech/can" + "go.einride.tech/can/pkg/generated" + "go.uber.org/zap" +) + +const ( + // MAX_FRAME_INTERVAL is the milliseconds between using identical CAN frames + MAX_FRAME_INTERVAL = 100 +) + +type DbcMessagesDescriptor interface { + UnmarshalFrame(f can.Frame) (generated.Message, error) +} + +type DataAcquisitionHandler struct { + md DbcMessagesDescriptor + telemetry *TelemetryHandler + busName string + l *zap.Logger + frameArrivalMap map[uint32]time.Time +} + +func NewDaqHandler(md DbcMessagesDescriptor, telemetry *TelemetryHandler, busName string, l *zap.Logger) *DataAcquisitionHandler { + return &DataAcquisitionHandler{ + md: md, + l: l, + telemetry: telemetry, + busName: busName, + frameArrivalMap: map[uint32]time.Time{}, + } +} + +func (d *DataAcquisitionHandler) Name() string { + return fmt.Sprintf("DAQ Handler") +} + +func (d *DataAcquisitionHandler) Handle(broadcastChan chan canlink.TimestampedFrame, stopChan chan struct{}) error { + for { + select { + case <-stopChan: + d.l.Info("stopping handle") + case receivedFrame := <-broadcastChan: + // CAN frame received, parse it and queue it for transmission or file caching + + // We wont do any additional parsing for now, but it might be useful later + /* + * msg, err := d.md.UnmarshalFrame(receivedFrame.Frame) + * if err != nil { + * return errors.Wrap(err, "daq: handle:") + * } + */ + + fmt.Printf("daq: received frame: %s\n", receivedFrame.Frame.String()) + lastSeen, exists := d.frameArrivalMap[receivedFrame.Frame.ID] + + // Frame was already seen less than MAX_FRAME_INTERVAL ms ago, ignore it + if exists && time.Now().Sub(lastSeen).Milliseconds() < MAX_FRAME_INTERVAL { + break + } + + err := d.telemetry.Enqueue(receivedFrame, d.busName) + if err != nil { + fmt.Printf("daq: failed to enqueue frame: %s\n", err.Error()) + } + default: + } + } +} diff --git a/scripts/daq/dbc/pt.dbc b/scripts/daq/dbc/pt.dbc new file mode 100644 index 000000000..9d74735bf --- /dev/null +++ b/scripts/daq/dbc/pt.dbc @@ -0,0 +1,92 @@ +VERSION "" + + +NS_ : + NS_DESC_ + CM_ + BA_DEF_ + BA_ + VAL_ + CAT_DEF_ + CAT_ + FILTER + BA_DEF_DEF_ + EV_DATA_ + ENVVAR_DATA_ + SGTYPE_ + SGTYPE_VAL_ + BA_DEF_SGTYPE_ + BA_SGTYPE_ + SIG_TYPE_REF_ + VAL_TABLE_ + SIG_GROUP_ + SIG_VALTYPE_ + SIGTYPE_VALTYPE_ + BO_TX_BU_ + BA_DEF_REL_ + BA_REL_ + BA_DEF_DEF_REL_ + BU_SG_REL_ + BU_EV_REL_ + BU_BO_REL_ + SG_MUL_VAL_ + +BS_: + +BU_: INV1 INV2 FC + +BO_ 643 Inv1_ActualValues1: 8 INV1 + SG_ bSystemReady : 8|1@1+ (1,0) [0|0] "" FC + SG_ bError : 9|1@1+ (1,0) [0|0] "" FC + SG_ bWarn : 10|1@1+ (1,0) [0|0] "" FC + SG_ bQuitDcOn : 11|1@1+ (1,0) [0|0] "" FC + SG_ bDcOn : 12|1@1+ (1,0) [0|0] "" FC + SG_ bQuitInverterOn : 13|1@1+ (1,0) [0|0] "" FC + SG_ bInverterOn : 14|1@1+ (1,0) [0|0] "" FC + SG_ bDerating : 15|1@1+ (1,0) [0|0] "" FC + SG_ ActualVelocity : 16|16@1- (1,0) [0|0] "rpm" FC + SG_ TorqueCurrent : 32|16@1- (1,0) [0|0] "" FC + SG_ MagnetizingCurrent : 48|16@1- (1,0) [0|0] "" FC + +BO_ 644 Inv2_ActualValues1: 8 INV2 + SG_ bSystemReady : 8|1@1+ (1,0) [0|0] "" FC + SG_ bError : 9|1@1+ (1,0) [0|0] "" FC + SG_ bWarn : 10|1@1+ (1,0) [0|0] "" FC + SG_ bQuitDcOn : 11|1@1+ (1,0) [0|0] "" FC + SG_ bDcOn : 12|1@1+ (1,0) [0|0] "" FC + SG_ bQuitInverterOn : 13|1@1+ (1,0) [0|0] "" FC + SG_ bInverterOn : 14|1@1+ (1,0) [0|0] "" FC + SG_ bDerating : 15|1@1+ (1,0) [0|0] "" FC + SG_ ActualVelocity : 16|16@1- (1,0) [0|0] "rpm" FC + SG_ TorqueCurrent : 32|16@1- (1,0) [0|0] "" FC + SG_ MagnetizingCurrent : 48|16@1- (1,0) [0|0] "" FC + +BO_ 645 Inv1_ActualValues2: 8 INV1 + SG_ TempMotor : 0|16@1- (0.1,0) [0|0] "degC" FC + SG_ TempInverter : 16|16@1- (0.1,0) [0|0] "degC" FC + SG_ ErrorInfo : 32|16@1+ (1,0) [0|0] "" FC + SG_ TempIGBT : 48|16@1- (0.1,0) [0|0] "degC" FC + +BO_ 646 Inv2_ActualValues2: 8 INV2 + SG_ TempMotor : 0|16@1- (0.1,0) [0|0] "degC" FC + SG_ TempInverter : 16|16@1- (0.1,0) [0|0] "degC" FC + SG_ ErrorInfo : 32|16@1+ (1,0) [0|0] "" FC + SG_ TempIGBT : 48|16@1- (0.1,0) [0|0] "degC" FC + +BO_ 388 Inv1_Setpoints1: 8 FC + SG_ bInverterOn : 8|1@1+ (1,0) [0|0] "" INV2 + SG_ bDcOn : 9|1@1+ (1,0) [0|0] "" INV2 + SG_ bEnable : 10|1@1+ (1,0) [0|0] "" INV2 + SG_ bErrorReset : 11|1@1+ (1,0) [0|0] "" INV2 + SG_ TargetVelocity : 16|16@1- (1,0) [0|0] "rpm" INV2 + SG_ TorqueLimitPositiv : 32|16@1- (1,0) [0|0] "0.1%" INV2 + SG_ TorqueLimitNegativ : 48|16@1- (1,0) [0|0] "0.1%" INV2 + +BO_ 389 Inv2_Setpoints1: 8 FC + SG_ bInverterOn : 8|1@1+ (1,0) [0|0] "" INV2 + SG_ bDcOn : 9|1@1+ (1,0) [0|0] "" INV2 + SG_ bEnable : 10|1@1+ (1,0) [0|0] "" INV2 + SG_ bErrorReset : 11|1@1+ (1,0) [0|0] "" INV2 + SG_ TargetVelocity : 16|16@1- (1,0) [0|0] "rpm" INV2 + SG_ TorqueLimitPositiv : 32|16@1- (1,0) [0|0] "0.1%" INV2 + SG_ TorqueLimitNegativ : 48|16@1- (1,0) [0|0] "0.1%" INV2 diff --git a/scripts/daq/dbc/veh.dbc b/scripts/daq/dbc/veh.dbc new file mode 100644 index 000000000..b0a9d44c2 --- /dev/null +++ b/scripts/daq/dbc/veh.dbc @@ -0,0 +1,402 @@ +VERSION "" + + +NS_ : + NS_DESC_ + CM_ + BA_DEF_ + BA_ + VAL_ + CAT_DEF_ + CAT_ + FILTER + BA_DEF_DEF_ + EV_DATA_ + ENVVAR_DATA_ + SGTYPE_ + SGTYPE_VAL_ + BA_DEF_SGTYPE_ + BA_SGTYPE_ + SIG_TYPE_REF_ + VAL_TABLE_ + SIG_GROUP_ + SIG_VALTYPE_ + SIGTYPE_VALTYPE_ + BO_TX_BU_ + BA_DEF_REL_ + BA_REL_ + BA_DEF_DEF_REL_ + BU_SG_REL_ + BU_EV_REL_ + BU_BO_REL_ + SG_MUL_VAL_ + +BS_: + +BU_: FC LVC TMS DASH RPI IMD BMS GPS + +CM_ "ID Scheme: +Replace x with the pertinent ECU (not necessarily the sender) +x=0 Front Controller +x=1 LV Controller +x=2 TMS +x=3 Dashboard +x=4 RPI + +Increment n as needed"; + +CM_ "1xn - Irregular, high importance commands"; + +BO_ 140 InitiateCanFlash: 1 RPI + SG_ ECU : 0|8@1+ (1,0) [0|2] "" FC, LVC, TMS + +VAL_ 140 ECU 0 "FrontController" 1 "LvController" 2 "TMS"; + +CM_ "Periodic Messages +2x0 - ECUx Commands +2x1 - ECUx Statuses +2x2 - ECUx Alerts + +Each 2x1 status should contain an 8-bit counter field which increments +on each transmission to show that the ECU is alive."; + +BO_ 201 FcStatus: 8 FC + SG_ Counter : 0|8@1+ (1,0) [0|255] "" RPI + SG_ State : 8|8@1+ (1,0) [0|255] "" RPI + SG_ AccumulatorState : 16|8@1+ (1,0) [0|255] "" RPI + SG_ MotorState : 24|8@1+ (1,0) [0|255] "" RPI + SG_ Inv1State : 32|4@1+ (1,0) [0|255] "" RPI + SG_ Inv2State : 36|4@1+ (1,0) [0|255] "" RPI + SG_ DbcValid : 40|1@1+ (1,0) [0|1] "" RPI + SG_ Inv1Starter : 48|4@1+ (1,0) [0|255] "" RPI + SG_ Inv2Starter : 52|4@1+ (1,0) [0|255] "" RPI + +VAL_ 201 State 0 "START_DASHBOARD" 2 "WAIT_DRIVER_SELECT" 3 "WAIT_START_HV" 4 "STARTING_HV" 5 "WAIT_START_MOTOR" 6 "STARTING_MOTORS" 7 "STARTUP_SEND_READY_TO_DRIVE" 8 "RUNNING" 9 "SHUTDOWN" 10 "ERROR"; +VAL_ 201 MotorState 0 "IDLE" 1 "STARTING" 2 "SWITCHING_INVERTER_ON" 3 "RUNNING" 4 "ERROR"; +VAL_ 201 AccumulatorState 0 "IDLE" 1 "STARTUP_ENSURE_OPEN" 2 "STARTUP_CLOSE_NEG" 3 "STARTUP_HOLD_CLOSE_NEG" 4 "STARTUP_CLOSE_PRECHARGE" 5 "STARTUP_HOLD_CLOSE_PRECHARGE" 6 "STARTUP_CLOSE_POS" 7 "STARTUP_HOLD_CLOSE_POS" 8 "STARTUP_OPEN_PRECHARGE" 9 "RUNNING" 10 "SHUTDOWN" 11 "ERROR"; +VAL_ 201 Inv1State 0 "OFF" 1 "SYSTEM_READY" 2 "STARTUP_bDCON" 3 "STARTUP_bENABLE" 4 "STARTUP_bINVERTER" 5 "STARTUP_X140" 6 "RUNNING" 7 "ERROR" 8 "ERROR_RESET"; +VAL_ 201 Inv2State 0 "OFF" 1 "SYSTEM_READY" 2 "STARTUP_bDCON" 3 "STARTUP_bENABLE" 4 "STARTUP_bINVERTER" 5 "STARTUP_X140" 6 "RUNNING" 7 "ERROR" 8 "ERROR_RESET"; + +BO_ 202 FcAlerts: 2 FC + SG_ AppsImplausible : 0|1@1+ (1,0) [0|1] "" RPI, DASH + SG_ AccumulatorLowSoc : 1|1@1+ (1,0) [0|1] "" RPI, DASH + SG_ AccumulatorContactorWrongState : 2|1@1+ (1,0) [0|1] "" RPI, DASH + SG_ MotorRetriesExceeded : 3|1@1+ (1,0) [0|1] "" RPI, DASH + SG_ LeftMotorStartingError : 4|1@1+ (1,0) [0|1] "" RPI, DASH + SG_ RightMotorStartingError : 5|1@1+ (1,0) [0|1] "" RPI, DASH + SG_ LeftMotorRunningError : 6|1@1+ (1,0) [0|1] "" RPI, DASH + SG_ RightMotorRunningError : 7|1@1+ (1,0) [0|1] "" RPI, DASH + SG_ DashboardBootTimeout : 8|1@1+ (1,0) [0|1] "" RPI, DASH + SG_ CanTxError : 9|1@1+ (1,0) [0|1] "" RPI, DASH + SG_ EV47Active : 10|1@1+ (1,0) [0|1] "" RPI, DASH + SG_ NoInv1Can : 11|1@1+ (1,0) [0|1] "" RPI, DASH + SG_ NoInv2Can : 12|1@1+ (1,0) [0|1] "" RPI, DASH + +BO_ 203 FcCounters: 8 FC + SG_ Motor : 0|8@1+ (1,0) [0|1] "" RPI, DASH + SG_ Amk1: 8|8@1+ (1,0) [0|1] "" RPI, DASH + SG_ Amk2 : 16|8@1+ (1,0) [0|1] "" RPI, DASH + SG_ Starter1 : 24|8@1+ (1,0) [0|1] "" RPI, DASH + SG_ Starter2 : 32|8@1+ (1,0) [0|1] "" RPI, DASH + +BO_ 210 LvCommand: 1 FC + SG_ BrakeLightEnable : 1|1@1+ (1,0) [0|1] "" LVC + +BO_ 213 InverterSwitchCommand: 1 FC + SG_ CloseInverterSwitch : 0|1@1+ (1,0) [0|1] "" LVC + +CM_ BO_ 213 "This should be combined with LvCommand once testing is done."; + +BO_ 211 LvStatus: 4 LVC + SG_ Counter : 0|8@1+ (1,0) [0|255] "" FC + SG_ LvState : 8|8@1+ (1,0) [0|1] "" FC + SG_ MotorControllerState : 16|8@1+ (1,0) [0|1] "" FC + SG_ MotorControllerSwitchClosed : 24|1@1+ (1,0) [0|1] "" FC + SG_ ImdFault : 25|1@1+ (1,0) [0|1] "" FC + SG_ BmsFault : 26|1@1+ (1,0) [0|1] "" FC + +VAL_ 211 LvState 0 "PWRUP_START" 1 "PWRUP_TSSI_ON" 2 "PWRUP_PERIPHERALS_ON" 3 "PWRUP_ACCUMULATOR_ON" 4 "PWRUP_MOTOR_CONTROLLER_PRECHARGING" 7 "PWRUP_SHUTDOWN_ON" 9 "DCDC_ON" 10 "POWERTRAIN_PUMP_ON" 11 "POWERTRAIN_FAN_ON" 13 "READY_TO_DRIVE" 14 "SHUTDOWN_DRIVER_WARNING" 15 "SHUTDOWN_PUMP_OFF" 16 "SHUTDOWN_FAN_OFF" 17 "SHUTDOWN_COMPLETE"; +VAL_ 211 MotorControllerState 0 "OFF" 1 "PRECHARGING" 2 "PRECHARGING_HANDOFF" 3 "ON"; + +BO_ 230 DashCommand: 5 FC + SG_ ConfigReceived : 0|1@1+ (1,0) [0|1] "" DASH + SG_ HvStarted : 1|1@1+ (1,0) [0|1] "" DASH + SG_ MotorStarted : 2|1@1+ (1,0) [0|1] "" DASH + SG_ DriveStarted : 3|1@1+ (1,0) [0|1] "" DASH + SG_ Errored : 5|1@1+ (1,0) [0|1] "" DASH + SG_ HvPrechargePercent : 8|8@1+ (1,0) [0|100] "" DASH + SG_ Speed : 16|12@1+ (0.1,0) [0|100] "mph" DASH + SG_ HvSocPercent : 32|8@1+ (1,0) [0|100] "" DASH + +BO_ 231 DashStatus: 3 DASH + SG_ Counter : 0|8@1+ (1,0) [0|255] "" FC + SG_ State : 8|8@1+ (1,0) [0|1] "" FC + SG_ Profile : 16|8@1+ (1,0) [0|15] "" FC + +VAL_ 231 State 0 "LOGO" 1 "SELECT_PROFILE" 2 "CONFIRM_SELECTION" 3 "WAIT_SELECTION_ACK" 4 "PRESS_FOR_HV" 5 "STARTING_HV" 6 "PRESS_FOR_MOTOR" 7 "STARTING_MOTORS" 8 "BRAKE_TO_START" 9 "RUNNING" 10 "SHUTDOWN" 11 "ERROR"; +VAL_ 231 Profile 0 "Default" 1 "Launch" 2 "Skidpad" 3 "Endurance" 4 "Tuning" 5 "_ENUM_TAIL_"; + +BO_ 300 Accumulator_Soc: 8 FC + SG_ PackVoltage : 0|16@1+ (1,0) [0|255] "" DASH + SG_ PrechargeVoltage : 16|16@1+ (1,0) [0|255] "" DASH + SG_ MaxPackVoltage : 32|16@1+ (1,0) [0|255] "" DASH + SG_ SocPercent : 48|8@1+ (1,0) [0|255] "" DASH + SG_ PrechargePercent : 56|8@1+ (1,0) [0|255] "" DASH + +CM_ "3xn - Additional sensor readings"; + +BO_ 310 SuspensionTravel34: 2 LVC + SG_ STP3 : 0|8@1+ (1,0) [0|255] "" FC + SG_ STP4 : 8|8@1+ (1,0) [0|255] "" FC + +BO_ 340 TuningParams: 8 RPI + SG_ aggressiveness : 0|8@1+ (1,0) [0|100] "" FC + +CM_ "4xn - General / debugging info"; + +BO_ 400 FcGitHash: 5 FC + SG_ Commit : 0|32@1+ (1,0) [0|0] "" RPI + SG_ Dirty : 32|1@1+ (1,0) [0|0] "" RPI + +BO_ 410 LvGitHash: 5 LVC + SG_ Commit : 0|32@1+ (1,0) [0|0] "" RPI + SG_ Dirty : 32|1@1+ (1,0) [0|0] "" RPI + +BO_ 420 TmsGitHash: 5 LVC + SG_ Commit : 0|32@1+ (1,0) [0|0] "" RPI + SG_ Dirty : 32|1@1+ (1,0) [0|0] "" RPI + +BO_ 430 DashGitHash: 5 DASH + SG_ Commit : 0|32@1+ (1,0) [0|0] "" RPI + SG_ Dirty : 32|1@1+ (1,0) [0|0] "" RPI + +BO_ 401 AppsDebug: 8 FC + SG_ Apps1RawVolt : 0|16@1+ (0.001,0) [0|0] "volt" RPI + SG_ Apps2RawVolt : 16|16@1+ (0.001,0) [0|0] "volt" RPI + SG_ Apps1Percent : 32|16@1+ (0.1,0) [0|0] "percent" RPI + SG_ Apps2Percent : 48|16@1+ (0.1,0) [0|0] "percent" RPI + +BO_ 402 BppsSteerDebug: 8 FC + SG_ BppsRawVolt : 0|16@1+ (0.001,0) [0|0] "volt" RPI + SG_ SteerRawVolt : 16|16@1+ (0.001,0) [0|0] "volt" RPI + SG_ BppsPercent : 32|16@1+ (0.1,0) [0|0] "percent" RPI + SG_ SteerPosition : 48|16@1+ (0.01,-1) [0|0] "[-1,+1]" RPI + +BO_ 411 LvDbcHash: 8 LVC + SG_ Hash : 0|64@1+ (1,0) [0|1] "" FC + +CM_ "Manufacturer specific IDs (do not modify) +55 IMD +769-777 GPS/IMU +1570-1574 BMS Command/Status +2553934720 BMS Temperatures 1 +2566844926 BMS Temperatures 2"; + +BO_ 55 IMD_Info_General: 8 IMD + SG_ rIsoCorrected : 0|16@1+ (1,0) [0|0] "" RPI + SG_ rIsoStatus : 16|8@1+ (1,0) [0|0] "" RPI + SG_ MeasurementCounter : 24|8@1+ (1,0) [0|0] "" RPI + SG_ WarningsAlarms : 32|16@1+ (1,0) [0|0] "" RPI + SG_ DeviceActivity : 48|8@1+ (1,0) [0|0] "" RPI + SG_ Reserved : 56|8@1+ (1,0) [0|0] "" RPI + +BO_ 56 IMD_InfoIsolationDetail: 8 IMD + SG_ rIsoNeg : 0|16@1+ (1,0) [0|0] "" RPI + SG_ rIsoPos : 16|16@1+ (1,0) [0|0] "" RPI + SG_ rIsoOriginal : 32|16@1+ (1,0) [0|0] "" RPI + SG_ isolationMeasurementCounter : 48|8@1+ (1,0) [0|0] "" RPI + SG_ isolationQuality : 56|8@1+ (1,0) [0|0] "" RPI + +BO_ 57 IMD_Info_Voltage: 8 IMD + SG_ hvSystem : 0|16@1+ (1,0) [0|0] "" RPI + SG_ hvNegToEarth : 16|16@1+ (1,0) [0|0] "" RPI + SG_ hvPosToEarth : 32|16@1+ (1,0) [0|0] "" RPI + SG_ voltageMeasurementCounter : 48|8@1+ (1,0) [0|0] "" RPI + SG_ Reserved : 56|8@1+ (1,0) [0|0] "" RPI + +BO_ 58 IMD_InfoItSystem: 8 IMD + SG_ capacityMeasuredValue : 0|16@1+ (1,0) [0|0] "" RPI + SG_ capacityMeasurementCounter : 16|8@1+ (1,0) [0|0] "" RPI + SG_ unbalanceMeasuredValue : 24|8@1+ (1,0) [0|0] "" RPI + SG_ unbalanceMeasurement : 32|8@1+ (1,0) [0|0] "" RPI + SG_ voltageMeasuredFrequency : 40|16@1+ (1,0) [0|0] "" RPI + SG_ Reserved : 56|8@1+ (1,0) [0|0] "" RPI + +BO_ 34 IMD_Request: 8 RPI + SG_ index : 0|8@1+ (1,0) [0|0] "" IMD + SG_ data0 : 8|8@1+ (1,0) [0|0] "" IMD + SG_ data1 : 16|8@1+ (1,0) [0|0] "" IMD + SG_ data2 : 24|8@1+ (1,0) [0|0] "" IMD + SG_ data3 : 32|8@1+ (1,0) [0|0] "" IMD + SG_ data4 : 40|8@1+ (1,0) [0|0] "" IMD + SG_ data5 : 48|8@1+ (1,0) [0|0] "" IMD + SG_ data6 : 56|8@1+ (1,0) [0|0] "" IMD + +BO_ 35 ID_Response: 8 IMD + SG_ index : 0|8@1+ (1,0) [0|0] "" RPI + SG_ d1 : 8|8@1+ (1,0) [0|0] "" RPI + SG_ d2 : 16|8@1+ (1,0) [0|0] "" RPI + SG_ d3 : 24|8@1+ (1,0) [0|0] "" RPI + SG_ d4 : 32|8@1+ (1,0) [0|0] "" RPI + SG_ d5 : 40|8@1+ (1,0) [0|0] "" RPI + SG_ d6 : 48|8@1+ (1,0) [0|0] "" RPI + SG_ d7 : 56|8@1+ (1,0) [0|0] "" RPI + +BO_ 1570 ContactorCommand: 3 FC + SG_ PackPositive : 0|8@1+ (1,0) [0|0] "" BMS + SG_ PackPrecharge : 8|8@1+ (1,0) [0|0] "" BMS + SG_ PackNegative : 16|8@1+ (1,0) [0|0] "" BMS + +BO_ 1572 Pack_State: 7 BMS + SG_ Pack_Current : 0|16@1+ (0.1,0) [0|0] "Amps" FC + SG_ Pack_Inst_Voltage : 16|16@1+ (0.1,0) [0|0] "Volts" FC + SG_ Avg_Cell_Voltage : 32|16@1+ (0.0001,0) [0|0] "Volts" FC + SG_ Populated_Cells : 48|8@1+ (1,0) [0|0] "Num" FC + +BO_ 1571 Pack_Current_Limits: 4 BMS + SG_ Pack_CCL : 0|16@1+ (1,0) [0|0] "Amps" FC + SG_ Pack_DCL : 16|16@1+ (1,0) [0|0] "Amps" FC + +BO_ 1573 Pack_SOC: 3 BMS + SG_ Pack_SOC : 0|8@1+ (0.5,0) [0|0] "Percent" FC + SG_ Maximum_Pack_Voltage : 8|16@1+ (0.1,0) [0|0] "Volts" FC + +BO_ 1574 Contactor_Feedback: 3 BMS + SG_ Pack_Positive_Feedback : 0|1@1+ (1,0) [0|1] "" FC, DASH, LVC + SG_ Pack_Negative_Feedback : 8|1@1+ (1,0) [0|1] "" FC, DASH, LVC + SG_ Pack_Precharge_Feedback : 16|1@1+ (1,0) [0|1] "" FC, DASH, LVC + +BO_ 2553934720 BmsBroadcast: 8 TMS + SG_ ThermModuleNum : 0|8@1+ (1,0) [0|0] "" BMS + SG_ LowThermValue : 8|8@1- (1,0) [0|0] " C" BMS + SG_ HighThermValue : 16|8@1- (1,0) [0|0] " C" BMS + SG_ AvgThermValue : 24|8@1- (1,0) [0|0] " C" BMS + SG_ NumThermEn : 32|8@1+ (1,0) [0|0] "" BMS + SG_ HighThermID : 40|8@1+ (1,0) [0|0] "" BMS + SG_ LowThermID : 48|8@1+ (1,0) [0|0] "" BMS + SG_ Checksum : 56|8@1+ (1,0) [0|0] "" BMS + +BO_ 2566844926 ThermistorBroadcast: 8 TMS + SG_ RelThermID : 0|16@1+ (1,0) [0|0] "" BMS + SG_ ThermValue : 16|8@1- (1,0) [0|0] " C" BMS + SG_ NumEnTherm : 24|8@1- (1,0) [0|0] "" BMS + SG_ LowThermValue : 32|8@1- (1,0) [0|0] " C" BMS + SG_ HighThermValue : 40|8@1- (1,0) [0|0] " C" BMS + SG_ HighThermID : 48|8@1+ (1,0) [0|0] "" BMS + SG_ LowThermID : 56|8@1+ (1,0) [0|0] "" BMS + +BO_ 769 GnssStatus: 1 GPS + SG_ FixType : 0|3@1+ (1,0) [0|5] "" RPI + SG_ Satellites : 3|5@1+ (1,0) [0|31] "" RPI + +BO_ 770 GnssTime: 6 GPS + SG_ TimeValid : 0|1@1+ (1,0) [0|1] "" RPI + SG_ TimeConfirmed : 1|1@1+ (1,0) [0|1] "" RPI + SG_ Epoch : 8|40@1+ (0.001,1577840400) [0|0] "sec" RPI + +BO_ 771 GnssPosition: 8 GPS + SG_ PositionValid : 0|1@1+ (1,0) [0|1] "" RPI + SG_ Latitude : 1|28@1+ (1E-006,-90) [-90|178.435455] "deg" RPI + SG_ Longitude : 29|29@1+ (1E-006,-180) [-180|356.870911] "deg" RPI + SG_ PositionAccuracy : 58|6@1+ (1,0) [0|63] "m" RPI + +BO_ 772 GnssAltitude: 4 GPS + SG_ AltitudeValid : 0|1@1+ (1,0) [0|1] "" RPI + SG_ Altitude : 1|18@1+ (0.1,-6000) [-6000|20000] "m" RPI + SG_ AltitudeAccuracy : 19|13@1+ (1,0) [0|8000] "m" RPI + +BO_ 773 GnssAttitude: 8 GPS + SG_ AttitudeValid : 0|1@1+ (1,0) [0|1] "" RPI + SG_ Roll : 1|12@1+ (0.1,-180) [-180|180] "deg" RPI + SG_ RollAccuracy : 13|9@1+ (0.1,0) [0|50] "deg" RPI + SG_ Pitch : 22|12@1+ (0.1,-90) [-90|90] "deg" RPI + SG_ PitchAccuracy : 34|9@1+ (0.1,0) [0|50] "deg" RPI + SG_ Heading : 43|12@1+ (0.1,0) [0|360] "deg" RPI + SG_ HeadingAccuracy : 55|9@1+ (0.1,0) [0|50] "deg" RPI + +BO_ 774 GnssOdo: 8 GPS + SG_ DistanceValid : 0|1@1+ (1,0) [0|1] "" RPI + SG_ DistanceTrip : 1|22@1+ (1,0) [0|4194303] "m" RPI + SG_ DistanceAccuracy : 23|19@1+ (1,0) [0|524287] "m" RPI + SG_ DistanceTotal : 42|22@1+ (1,0) [0|4194303] "km" RPI + +BO_ 775 GnssSpeed: 5 GPS + SG_ SpeedValid : 0|1@1+ (1,0) [0|1] "" RPI + SG_ Speed : 1|20@1+ (0.001,0) [0|1048.575] "m/s" RPI + SG_ SpeedAccuracy : 21|19@1+ (0.001,0) [0|524.287] "m/s" RPI + +BO_ 776 GnssGeofence: 2 GPS + SG_ FenceValid : 0|1@1+ (1,0) [0|1] "" RPI + SG_ FenceCombined : 1|2@1+ (1,0) [0|1] "" RPI + SG_ Fence1 : 8|2@1+ (1,0) [0|1] "" RPI + SG_ Fence2 : 10|2@1+ (1,0) [0|1] "" RPI + SG_ Fence3 : 12|2@1+ (1,0) [0|1] "" RPI + SG_ Fence4 : 14|2@1+ (1,0) [0|1] "" RPI + +BO_ 777 GnssImu: 8 GPS + SG_ ImuValid : 0|1@1+ (1,0) [0|1] "" RPI + SG_ AccelerationX : 1|10@1+ (0.125,-64) [-64|63.875] "m/s^2" RPI + SG_ AccelerationY : 11|10@1+ (0.125,-64) [-64|63.875] "m/s^2" RPI + SG_ AccelerationZ : 21|10@1+ (0.125,-64) [-64|63.875] "m/s^2" RPI + SG_ AngularRateX : 31|11@1+ (0.25,-256) [-256|255.75] "deg/s" RPI + SG_ AngularRateY : 42|11@1+ (0.25,-256) [-256|255.75] "deg/s" RPI + SG_ AngularRateZ : 53|11@1+ (0.25,-256) [-256|255.75] "deg/s" RPI + +CM_ BO_ 1572 "This ID Transmits at 8 ms."; +CM_ BO_ 1571 "This ID Transmits at 8 ms."; +CM_ BO_ 1573 "This ID Transmits at 8 ms."; +CM_ BO_ 1574 "This ID Transmits at 8 ms."; +CM_ BO_ 2553934720 "Thermistor Module - BMS Broadcast"; +CM_ SG_ 2553934720 ThermModuleNum "Thermistor Module Number"; +CM_ BO_ 2566844926 "Thermistor General Broadcast"; +CM_ SG_ 2566844926 RelThermID "Thermistor ID relative to all configured Thermistor Modules"; +CM_ BO_ 769 "GNSS information"; +CM_ SG_ 769 FixType "Fix type"; +CM_ SG_ 769 Satellites "Number of satellites used"; +CM_ BO_ 770 "GNSS time"; +CM_ SG_ 770 TimeValid "Time validity"; +CM_ SG_ 770 TimeConfirmed "Time confirmed"; +CM_ SG_ 770 Epoch "Epoch time"; +CM_ BO_ 771 "GNSS position"; +CM_ SG_ 771 PositionValid "Position validity"; +CM_ SG_ 771 Latitude "Latitude"; +CM_ SG_ 771 Longitude "Longitude"; +CM_ SG_ 771 PositionAccuracy "Accuracy of position"; +CM_ BO_ 772 "GNSS altitude"; +CM_ SG_ 772 AltitudeValid "Altitude validity"; +CM_ SG_ 772 Altitude "Altitude"; +CM_ SG_ 772 AltitudeAccuracy "Accuracy of altitude"; +CM_ BO_ 773 "GNSS attitude"; +CM_ SG_ 773 AttitudeValid "Attitude validity"; +CM_ SG_ 773 Roll "Vehicle roll"; +CM_ SG_ 773 RollAccuracy "Vehicle roll accuracy"; +CM_ SG_ 773 Pitch "Vehicle pitch"; +CM_ SG_ 773 PitchAccuracy "Vehicle pitch accuracy"; +CM_ SG_ 773 Heading "Vehicle heading"; +CM_ SG_ 773 HeadingAccuracy "Vehicle heading accuracy"; +CM_ BO_ 774 "GNSS odometer"; +CM_ SG_ 774 DistanceTrip "Distance traveled since last reset"; +CM_ SG_ 774 DistanceAccuracy "Distance accuracy (1-sigma)"; +CM_ SG_ 774 DistanceTotal "Distance traveled in total"; +CM_ BO_ 775 "GNSS speed"; +CM_ SG_ 775 SpeedValid "Speed valid"; +CM_ SG_ 775 Speed "Speed m/s"; +CM_ SG_ 775 SpeedAccuracy "Speed accuracy"; +CM_ BO_ 776 "GNSS geofence(s)"; +CM_ SG_ 776 FenceValid "Geofencing status"; +CM_ SG_ 776 FenceCombined "Combined (logical OR) state of all geofences"; +CM_ SG_ 776 Fence1 "Geofence 1 state"; +CM_ SG_ 776 Fence2 "Geofence 2 state"; +CM_ SG_ 776 Fence3 "Geofence 3 state"; +CM_ SG_ 776 Fence4 "Geofence 4 state"; +CM_ BO_ 777 "GNSS IMU"; +CM_ SG_ 777 AccelerationX "X acceleration with a resolution of 0.125 m/s^2"; +CM_ SG_ 777 AccelerationY "Y acceleration with a resolution of 0.125 m/s^2"; +CM_ SG_ 777 AccelerationZ "Z acceleration with a resolution of 0.125 m/s^2"; +CM_ SG_ 777 AngularRateX "X angular rate with a resolution of 0.25 deg/s"; +CM_ SG_ 777 AngularRateY "Y angular rate with a resolution of 0.25 deg/s"; +CM_ SG_ 777 AngularRateZ "Z angular rate with a resolution of 0.25 deg/s"; +BA_DEF_ "BusType" STRING ; +BA_DEF_ "MultiplexExtEnabled" ENUM "No","Yes"; +BA_DEF_DEF_ "BusType" "CAN"; +BA_DEF_DEF_ "MultiplexExtEnabled" "No"; \ No newline at end of file diff --git a/scripts/daq/go.mod b/scripts/daq/go.mod new file mode 100644 index 000000000..395da0651 --- /dev/null +++ b/scripts/daq/go.mod @@ -0,0 +1,28 @@ +module mac-daq + +go 1.24.0 + +require ( + github.com/macformula/hil v0.0.0-20250916142256-e7edb0f50362 + github.com/mattn/go-sqlite3 v1.14.32 + go.einride.tech/can v0.16.1 + go.uber.org/zap v1.26.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.36.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.39.1 // indirect +) diff --git a/scripts/daq/go.sum b/scripts/daq/go.sum new file mode 100644 index 000000000..194882953 --- /dev/null +++ b/scripts/daq/go.sum @@ -0,0 +1,59 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/macformula/hil v0.0.0-20250916142256-e7edb0f50362 h1:0ys7bStJcXBrn4hnf+Gr1JcnZgkwSeld2kKCpFD/Sjw= +github.com/macformula/hil v0.0.0-20250916142256-e7edb0f50362/go.mod h1:DZLE3YYqW7GatL2Hh80i04w/X1fQuKhkLoAy6o8a2Uw= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.einride.tech/can v0.16.1 h1:s9MqX1OR6ujGxvl+gOWAGL54MC3kaPE+cgxBCUfDrB8= +go.einride.tech/can v0.16.1/go.mod h1:9pgqXNGpPfrd/WGXGmiKW8cUvIep/o+o76JgUKpQuWI= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4= +modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= diff --git a/scripts/daq/grafana_dashboard.json b/scripts/daq/grafana_dashboard.json new file mode 100644 index 000000000..529feacc3 --- /dev/null +++ b/scripts/daq/grafana_dashboard.json @@ -0,0 +1,1684 @@ +{ + "__inputs": [ + { + "name": "DS_INFLUXDB", + "label": "InfluxDB", + "description": "InfluxDB Cloud datasource \u00e2\u20ac\u201d must use Flux query language", + "type": "datasource", + "pluginId": "influxdb", + "pluginName": "InfluxDB" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "panel", + "id": "gauge", + "name": "Gauge", + "version": "" + }, + { + "type": "panel", + "id": "table", + "name": "Table", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + }, + { + "type": "datasource", + "id": "influxdb", + "name": "InfluxDB", + "version": "1.0.0" + } + ], + "annotations": { + "list": [] + }, + "description": "MAC Formula Electric \u00e2\u20ac\u201d Real-time racecar telemetry", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 100, + "title": "Vehicle Safety", + "type": "row" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "mappings": [], + "min": 340, + "max": 620, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 374 + }, + { + "color": "green", + "value": 400 + }, + { + "color": "yellow", + "value": 590 + }, + { + "color": "red", + "value": 601 + } + ] + }, + "unit": "volt" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 4, + "x": 0, + "y": 1 + }, + "id": 1, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"Pack_Inst_Voltage\")\n |> filter(fn: (r) => r._field == \"value\")\n |> last()" + } + ], + "title": "Battery Voltage", + "type": "gauge" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "mappings": [], + "min": 0, + "max": 65, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 59 + } + ] + }, + "unit": "amp" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 4, + "x": 4, + "y": 1 + }, + "id": 2, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"Pack_Current\")\n |> filter(fn: (r) => r._field == \"value\")\n |> last()" + } + ], + "title": "Pack Current", + "type": "gauge" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "mappings": [], + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 10 + }, + { + "color": "green", + "value": 20 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 4, + "x": 8, + "y": 1 + }, + "id": 3, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"Pack_SOC\")\n |> filter(fn: (r) => r._field == \"value\")\n |> last()" + } + ], + "title": "SOC", + "type": "gauge" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "mappings": [], + "min": 0, + "max": 80, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 55 + }, + { + "color": "red", + "value": 70 + } + ] + }, + "unit": "celsius" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 4, + "x": 12, + "y": 1 + }, + "id": 4, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"TempMotor\")\n |> filter(fn: (r) => r._field == \"value\")\n |> last()" + } + ], + "title": "Motor Temp", + "type": "gauge" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "mappings": [], + "min": 0, + "max": 70, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 60 + } + ] + }, + "unit": "celsius" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 4, + "x": 16, + "y": 1 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"TempInverter\")\n |> filter(fn: (r) => r._field == \"value\")\n |> last()" + } + ], + "title": "Inverter Temp", + "type": "gauge" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "decimals": 1, + "mappings": [], + "min": 16, + "max": 30, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 19 + }, + { + "color": "green", + "value": 21 + } + ] + }, + "unit": "volt" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 4, + "x": 20, + "y": 1 + }, + "id": 6, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"lv_battery_voltage\")\n |> filter(fn: (r) => r._field == \"value\")\n |> last()" + } + ], + "title": "LV Battery", + "type": "gauge" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "severity" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-background", + "mode": "basic" + } + }, + { + "id": "mappings", + "value": [ + { + "options": { + "WARNING": { + "color": "yellow", + "index": 0 + } + }, + "type": "value" + }, + { + "options": { + "CRITICAL": { + "color": "red", + "index": 1 + } + }, + "type": "value" + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "_time" + }, + "properties": [ + { + "id": "displayName", + "value": "Time" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "system" + }, + "properties": [ + { + "id": "displayName", + "value": "System" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "fault" + }, + "properties": [ + { + "id": "displayName", + "value": "Fault" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "severity" + }, + "properties": [ + { + "id": "displayName", + "value": "Severity" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 9 + }, + "id": 7, + "options": { + "cellHeight": "sm", + "frameHeight": 0, + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Time" + } + ] + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"faults\")\n |> filter(fn: (r) => r._field == \"active\")\n |> group()\n |> keep(columns: [\"_time\", \"system\", \"fault\", \"severity\"])\n |> sort(columns: [\"_time\"], desc: true)\n |> limit(n: 100)" + } + ], + "title": "Fault Log", + "transformations": [ + { + "id": "organize", + "options": { + "renameByName": {}, + "indexByName": { + "_time": 0, + "system": 1, + "fault": 2, + "severity": 3 + }, + "excludeByName": { + "table": true + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "lineWidth": 2, + "fillOpacity": 10, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "battery_voltage" + }, + "properties": [ + { + "id": "unit", + "value": "volt" + }, + { + "id": "custom.axisPlacement", + "value": "left" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "pack_current" + }, + "properties": [ + { + "id": "unit", + "value": "amp" + }, + { + "id": "custom.axisPlacement", + "value": "right" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 17 + }, + "id": 8, + "options": { + "legend": { + "calcs": [ + "min", + "max", + "last" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"Pack_Inst_Voltage\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"battery_voltage\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "B", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"Pack_Current\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"pack_current\")" + } + ], + "title": "Battery Voltage & Pack Current", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 25 + }, + "id": 101, + "title": "Performance", + "type": "row" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "green" + }, + "custom": { + "lineWidth": 2, + "fillOpacity": 10, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "velocitykmh" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 26 + }, + "id": 10, + "options": { + "legend": { + "calcs": [ + "min", + "max", + "last" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"Speed\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)" + } + ], + "title": "Vehicle Speed (km/h)", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "yellow" + }, + "custom": { + "lineWidth": 2, + "fillOpacity": 10, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 59 + } + ] + }, + "unit": "amp" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 34 + }, + "id": 11, + "options": { + "legend": { + "calcs": [ + "min", + "max", + "last" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"Pack_Current\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)" + } + ], + "title": "Pack Current (A)", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "lineWidth": 2, + "fillOpacity": 5, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "rotrpm" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 42 + }, + "id": 12, + "options": { + "legend": { + "calcs": [ + "min", + "max", + "last" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"ActualVelocity\")\n |> filter(fn: (r) => r.inverter == \"inv1\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Left Motor\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "B", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"ActualVelocity\")\n |> filter(fn: (r) => r.inverter == \"inv2\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Right Motor\")" + } + ], + "title": "Dual Motor RPM", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 50 + }, + "id": 102, + "title": "Segment Temperatures", + "type": "row" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "lineWidth": 2, + "fillOpacity": 5, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 60 + } + ] + }, + "unit": "celsius", + "min": 0, + "max": 70 + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 51 + }, + "id": 20, + "options": { + "legend": { + "calcs": [ + "min", + "max", + "last" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"HighThermValue\")\n |> filter(fn: (r) => r.module == \"0\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Max\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "B", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"LowThermValue\")\n |> filter(fn: (r) => r.module == \"0\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Min\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "C", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"AvgThermValue\")\n |> filter(fn: (r) => r.module == \"0\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Avg\")" + } + ], + "title": "Segment 1 Temp", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "lineWidth": 2, + "fillOpacity": 5, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 60 + } + ] + }, + "unit": "celsius", + "min": 0, + "max": 70 + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 51 + }, + "id": 21, + "options": { + "legend": { + "calcs": [ + "min", + "max", + "last" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"HighThermValue\")\n |> filter(fn: (r) => r.module == \"1\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Max\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "B", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"LowThermValue\")\n |> filter(fn: (r) => r.module == \"1\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Min\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "C", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"AvgThermValue\")\n |> filter(fn: (r) => r.module == \"1\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Avg\")" + } + ], + "title": "Segment 2 Temp", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "lineWidth": 2, + "fillOpacity": 5, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 60 + } + ] + }, + "unit": "celsius", + "min": 0, + "max": 70 + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 59 + }, + "id": 22, + "options": { + "legend": { + "calcs": [ + "min", + "max", + "last" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"HighThermValue\")\n |> filter(fn: (r) => r.module == \"2\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Max\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "B", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"LowThermValue\")\n |> filter(fn: (r) => r.module == \"2\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Min\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "C", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"AvgThermValue\")\n |> filter(fn: (r) => r.module == \"2\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Avg\")" + } + ], + "title": "Segment 3 Temp", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "lineWidth": 2, + "fillOpacity": 5, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 60 + } + ] + }, + "unit": "celsius", + "min": 0, + "max": 70 + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 59 + }, + "id": 23, + "options": { + "legend": { + "calcs": [ + "min", + "max", + "last" + ], + "displayMode": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"HighThermValue\")\n |> filter(fn: (r) => r.module == \"3\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Max\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "B", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"LowThermValue\")\n |> filter(fn: (r) => r.module == \"3\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Min\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "C", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"AvgThermValue\")\n |> filter(fn: (r) => r.module == \"3\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Avg\")" + } + ], + "title": "Segment 4 Temp", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "lineWidth": 2, + "fillOpacity": 5, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 60 + } + ] + }, + "unit": "celsius", + "min": 0, + "max": 70 + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 67 + }, + "id": 24, + "options": { + "legend": { + "calcs": [ + "min", + "max", + "last" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"HighThermValue\")\n |> filter(fn: (r) => r.module == \"4\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Max\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "B", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"LowThermValue\")\n |> filter(fn: (r) => r.module == \"4\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Min\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "C", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"AvgThermValue\")\n |> filter(fn: (r) => r.module == \"4\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Avg\")" + } + ], + "title": "Segment 5 Temp", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "lineWidth": 2, + "fillOpacity": 5, + "spanNulls": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 60 + } + ] + }, + "unit": "celsius", + "min": 0, + "max": 70 + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 67 + }, + "id": 25, + "options": { + "legend": { + "calcs": [ + "min", + "max", + "last" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"HighThermValue\")\n |> filter(fn: (r) => r.module == \"5\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Max\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "B", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"LowThermValue\")\n |> filter(fn: (r) => r.module == \"5\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Min\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "C", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"AvgThermValue\")\n |> filter(fn: (r) => r.module == \"5\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"Avg\")" + } + ], + "title": "Segment 6 Temp", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 75 + }, + "id": 200, + "title": "Pedal Inputs", + "type": "row" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "lineWidth": 2, + "fillOpacity": 10, + "spanNulls": false + }, + "mappings": [], + "min": 0, + "max": 100, + "unit": "percent", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "apps1" + }, + "properties": [ + { + "id": "displayName", + "value": "APPS 1" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "apps2" + }, + "properties": [ + { + "id": "displayName", + "value": "APPS 2" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "bpps" + }, + "properties": [ + { + "id": "displayName", + "value": "BPPS" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 76 + }, + "id": 201, + "options": { + "legend": { + "calcs": [ + "min", + "max", + "last" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "A", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"Apps1Percent\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"apps1\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "B", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"Apps2Percent\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"apps2\")" + }, + { + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "refId": "C", + "query": "from(bucket: \"macfe\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"car_data\")\n |> filter(fn: (r) => r.signal == \"BppsPercent\")\n |> filter(fn: (r) => r._field == \"value\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"bpps\")" + } + ], + "title": "APPS & BPPS (%)", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 38, + "tags": [ + "racecar", + "daq", + "macformula" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Racecar DAQ", + "uid": "macformula-racecar-daq", + "version": 1 +} \ No newline at end of file diff --git a/scripts/daq/heartbeat.go b/scripts/daq/heartbeat.go new file mode 100644 index 000000000..3e9629d71 --- /dev/null +++ b/scripts/daq/heartbeat.go @@ -0,0 +1,94 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "time" + + "go.uber.org/zap" +) + +type HeartbeatHandler struct { + serverURL string + vehicleID string + sessionID string + can0 net.Conn + can1 net.Conn + logger *zap.Logger +} + +func NewHeartbeatHandler(can0, can1 net.Conn, logger *zap.Logger) *HeartbeatHandler { + + // Configuring Vehicle ID + vehicleID := os.Getenv("VEHICLE_ID") + if vehicleID == "" { + logger.Error("VEHICLE_ID not found, using default.") + vehicleID = "default" + } + + // Generating Session ID + sessionBytes := make([]byte, 16) + _, err := rand.Read(sessionBytes) + var sessionID string + if err != nil { + logger.Warn("Failed to generate session ID, defaulting to time based session ID.") + sessionID = fmt.Sprintf("fallback-%d", time.Now().UnixNano()) + } else { + sessionID = hex.EncodeToString(sessionBytes) + } + + serverURL := os.Getenv("SERVER_URL") + if serverURL == "" { + logger.Error("SERVER_URL for heartbeatnot found") + } + + return &HeartbeatHandler{ + serverURL: serverURL, + vehicleID: vehicleID, + sessionID: sessionID, + can0: can0, + can1: can1, + logger: logger, + } +} + +func (h *HeartbeatHandler) SendHeartbeat() error { + can0Active, can1Active := h.checkCAN() + + payload := map[string]interface{}{ + "timestamp": time.Now().UnixMilli(), + "vehicle_id": h.vehicleID, + "session_id": h.sessionID, + "can_status": map[string]bool{ + "can0": can0Active, + "can1": can1Active, + }, + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + h.logger.Error("Failed to convert heartbeat payload to JSON", zap.Error(err)) + return err + } + + response, err := http.Post(h.serverURL, "application/json", bytes.NewBuffer(jsonPayload)) + if err != nil { + h.logger.Error("Failed to send heartbeat", zap.Error(err)) + return err + } + defer response.Body.Close() + + return nil +} + +func (h *HeartbeatHandler) checkCAN() (bool, bool) { + can0Active := h.can0 != nil + can1Active := h.can1 != nil + return can0Active, can1Active +} diff --git a/scripts/daq/logger.py b/scripts/daq/logger.py new file mode 100644 index 000000000..a1bc31ed5 --- /dev/null +++ b/scripts/daq/logger.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +""" +Raspberry Pi CAN Data Logger. + +Reads CAN messages from one or two SocketCAN interfaces via candump, decodes +them using the vehicle and powertrain DBC files, writes decoded signals to a +timestamped CSV file, and streams key telemetry and fault events to InfluxDB. + +Usage: + python logger.py # Pi: reads can0 can1 + python logger.py --interfaces vcan0 vcan1 # VM: virtual CAN + python logger.py --interfaces can0 # single interface +""" + +import argparse +import queue +import re +import signal +import subprocess +import sys +import threading +import os +from datetime import datetime +from pathlib import Path + +import cantools +from influxdb_client import InfluxDBClient, Point +from dotenv import load_dotenv + +load_dotenv(override=True) + +# --------------------------------------------------------------------------- +# Target message IDs — what we care about logging. +# +# How these IDs work: +# Standard CAN frames use 11-bit IDs (0x000–0x7FF). +# Extended CAN frames use 29-bit IDs. +# +# In the DBC file, extended messages are written with bit 31 set, e.g.: +# BO_ 2553934720 BmsBroadcast ... +# (2553934720 = 0x9839F380, bit 31 set) +# +# cantools strips bit 31 when it parses the DBC and stores the actual +# 29-bit frame ID: +# BmsBroadcast.frame_id == 0x1839F380 == 406451072 +# +# candump prints extended IDs as 8 hex digits without bit 31: +# (t) vcan0 1839F380#... +# +# So the IDs here must match cantools (29-bit, no bit-31 flag). +# --------------------------------------------------------------------------- +TARGET_IDS = { + 202, # FcAlerts (veh) - 13 boolean fault flags + 211, # LvStatus (veh) - ImdFault, BmsFault + 214, # LvDcdc (veh) - LV battery/bus voltage + bus current + 230, # DashCommand (veh) - Speed + 300, # Accumulator_Soc (veh) - battery state + 310, # SuspensionTravel34 (veh) - linear pots + 401, # AppsDebug (veh) - accelerator pedal pots + 402, # BppsSteerDebug (veh) - brake pedal + steering pots + 643, # Inv1_ActualValues1 (pt) - left motor velocity + 644, # Inv2_ActualValues1 (pt) - right motor velocity + 645, # Inv1_ActualValues2 (pt) - left motor/inverter temps + 646, # Inv2_ActualValues2 (pt) - right motor/inverter temps + 773, # GnssAttitude (veh) - IMU roll/pitch/heading + 777, # GnssImu (veh) - IMU accelerations + rates + 1572, # Pack_State (veh) - battery current/voltage + 1573, # Pack_SOC (veh) - battery state of charge + 406451072, # BmsBroadcast (veh) - pack temps per segment (extended ID 0x1839F380) + 419361278, # ThermistorBroadcast (veh) - thermistor temps (extended ID 0x18FEF1FE) +} + +# Signal names written to InfluxDB — must match DBC signal names exactly. +IMPORTANT_SIGNALS = { + "Pack_Inst_Voltage", # HV battery voltage (V) — Pack_State (1572) + "Pack_Current", # HV pack current (A) — Pack_State (1572) + "Pack_SOC", # state of charge (%) — Pack_SOC (1573) + "ActualVelocity", # motor RPM — Inv1/2_ActualValues1 (643/644) + "TempMotor", # motor temperature (°C) — Inv1/2_ActualValues2 (645/646) + "TempInverter", # inverter temperature (°C) — Inv1/2_ActualValues2 (645/646) + "TempIGBT", # IGBT temperature (°C) — Inv1/2_ActualValues2 (645/646) + "Speed", # vehicle speed (mph) — DashCommand (230) + "HighThermValue", # segment max temp (°C) — BmsBroadcast (0x1839F380) + "LowThermValue", # segment min temp (°C) — BmsBroadcast + "AvgThermValue", # segment avg temp (°C) — BmsBroadcast + "Apps1Percent", # APPS sensor 1 position (%) — AppsDebug (401) + "Apps2Percent", # APPS sensor 2 position (%) — AppsDebug (401) + "BppsPercent", # brake pedal position (%) — BppsSteerDebug (402) + "LvBatteryVoltage", # LV battery voltage (V) — LvDcdc (214) + "BusVoltage", # DCDC bus voltage (V) — LvDcdc (214) + "BusCurrent", # DCDC bus current (A) — LvDcdc (214) +} + +# Boolean fault signals from FcAlerts (202) and LvStatus (211). +# Value == 1 means the fault is active. Each maps to (system, description, severity). +FAULT_SIGNALS = { + # FcAlerts (202) + "AppsImplausible": ("APPS", "Implausible Pedal Signal", "CRITICAL"), + "AccumulatorLowSoc": ("BMS", "Low State of Charge", "WARNING"), + "AccumulatorContactorWrongState": ("BMS", "Contactor Wrong State", "CRITICAL"), + "MotorRetriesExceeded": ("Motor", "Retries Exceeded", "CRITICAL"), + "LeftMotorStartingError": ("Motor", "Left Motor Start Error", "CRITICAL"), + "RightMotorStartingError": ("Motor", "Right Motor Start Error", "CRITICAL"), + "LeftMotorRunningError": ("Motor", "Left Motor Running Error", "CRITICAL"), + "RightMotorRunningError": ("Motor", "Right Motor Running Error", "CRITICAL"), + "DashboardBootTimeout": ("Dashboard", "Boot Timeout", "WARNING"), + "CanTxError": ("CAN", "TX Error", "WARNING"), + "EV47Active": ("Safety", "EV4.7 Rule Active", "CRITICAL"), + "NoInv1Can": ("Inverter", "No INV1 CAN Comm", "CRITICAL"), + "NoInv2Can": ("Inverter", "No INV2 CAN Comm", "CRITICAL"), + # LvStatus (211) + "ImdFault": ("IMD", "Isolation Fault", "CRITICAL"), + "BmsFault": ("BMS", "BMS Fault", "CRITICAL"), +} +SIGNAL_THRESHOLDS = { + "Pack_Current": (None, 59.0, "BMS", "Pack Overcurrent", "CRITICAL"), + "Pack_Inst_Voltage": (360.0, 600.0, "BMS", "Pack Voltage Out of Range", "CRITICAL"), + "TempMotor": (None, 70.0, "Motor", "Motor Overtemperature", "CRITICAL"), + "TempInverter": (None, 60.0, "Inverter", "Inverter Overtemperature", "CRITICAL"), + "TempIGBT": (None, 60.0, "Inverter", "IGBT Overtemperature", "CRITICAL"), + "HighThermValue": (None, 60.0, "BMS", "Battery Segment Overtemperature","CRITICAL"), + "LvBatteryVoltage": (18.0, 29.0, "LV", "LV Battery Voltage Out of Range","CRITICAL"), +} + +# CAN IDs that produce the same signal names for left and right — tagged "inverter". +INV1_IDS = {643, 645} +INV2_IDS = {644, 646} + +# candump -L log format: (timestamp) interface hex_id#hex_data +CANDUMP_LINE_RE = re.compile( + r'\((\d+\.\d+)\)\s+\S+\s+([0-9A-Fa-f]+)#([0-9A-Fa-f]*)' +) + +""" Influx DB Helper Functions """ +REQUIRED_INFLUX_ENV = ("INFLUX_URL", "INFLUX_TOKEN", "INFLUX_ORG", "INFLUX_BUCKET") +client: InfluxDBClient | None = None +write_api = None +influx_warning_printed = False + + +def init_influx() -> bool: + """Initialize optional InfluxDB telemetry; CSV logging does not depend on it.""" + missing = [name for name in REQUIRED_INFLUX_ENV if not os.getenv(name)] + if missing: + print( + f"Warning: InfluxDB disabled; missing environment variables: {', '.join(missing)}\n" + "CSV logging will continue locally.", + file=sys.stderr, + ) + return False + + global client, write_api + try: + client = InfluxDBClient( + url=os.getenv("INFLUX_URL"), + token=os.getenv("INFLUX_TOKEN"), + org=os.getenv("INFLUX_ORG"), + verify_ssl=False, + ) + write_api = client.write_api() + except Exception as exc: + print( + f"Warning: InfluxDB disabled; client setup failed: {exc}\n" + "CSV logging will continue locally.", + file=sys.stderr, + ) + client = None + write_api = None + return False + + print("InfluxDB telemetry enabled.", file=sys.stderr) + return True + + +def _write_influx_point(point: Point) -> None: + """Best-effort InfluxDB write; never interrupt local CSV logging.""" + global influx_warning_printed + if write_api is None: + return + + try: + write_api.write(bucket=os.getenv("INFLUX_BUCKET"), record=point) + except Exception as exc: + if not influx_warning_printed: + print( + f"Warning: InfluxDB write failed; continuing CSV logging only: {exc}", + file=sys.stderr, + ) + influx_warning_printed = True + + +def write_to_influx(sig_name: str, value: float, timestamp: float, extra_tags: dict = None) -> None: + if write_api is None: + return + + point = ( + Point("car_data") + .tag("signal", sig_name) + .field("value", float(value)) + .time(int(timestamp * 1e9)) + ) + if extra_tags: + for k, v in extra_tags.items(): + point = point.tag(k, v) + _write_influx_point(point) + + +def write_fault_to_influx(system: str, fault: str, severity: str, timestamp: float) -> None: + if write_api is None: + return + + point = ( + Point("faults") + .tag("system", system) + .tag("fault", fault) + .tag("severity", severity) + .field("active", 1) + .time(int(timestamp * 1e9)) + ) + _write_influx_point(point) +# ------------------------------------------------------------------------- + +def load_dbc(veh_dbc: Path, pt_dbc: Path | None) -> cantools.database.Database: + """Load veh.dbc and pt.dbc into a single cantools Database.""" + db = cantools.database.load_file(str(veh_dbc)) + if pt_dbc and pt_dbc.exists(): + with open(pt_dbc, encoding='utf-8') as f: + db.add_dbc(f) + return db + + +def open_csv(log_dir: Path) -> tuple[Path, object]: + """Create logs dir, open CSV with header, return (path, file).""" + log_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime('%Y%m%d_%H%M%S') + path = log_dir / f'can_log_{ts}.csv' + f = open(path, 'w', newline='', encoding='utf-8', buffering=1) + f.write('timestamp_epoch_s,message_id_hex,message_name,signal_name,value,unit\n') + return path, f + + +def parse_candump_line(line: str) -> tuple[float, int, bytes] | None: + """ + Parse a candump -L line into (timestamp_s, can_id, data_bytes). + + candump output format: + (1709650.123456) vcan0 136#8E91 + (1709650.456789) vcan0 1839F380#C2001816060A0041 + + The hex ID is printed as-is by candump — no bit-31 flag, just the raw + value. For extended frames this is 8 hex digits; for standard frames + it is up to 3 hex digits. We parse it directly so it matches cantools. + """ + m = CANDUMP_LINE_RE.match(line.strip()) + if not m: + return None + ts_str, hex_id, hex_data = m.groups() + if len(hex_data) % 2 != 0: # valid hex has to be even number of chars + return None + return float(ts_str), int(hex_id, 16), bytes.fromhex(hex_data) + + +def decode_frame( + db: cantools.database.Database, + can_id: int, + data: bytes, +) -> tuple[str, dict[str, tuple]] | None: + """ + Look up the message in the DBC by frame ID and decode the raw bytes. + + Returns (message_name, {signal_name: (value, unit)}) or None if the + message is not in the DBC or decoding fails. + + can id - data + """ + msg = next((m for m in db.messages if m.frame_id == can_id), None) #cycle thru dbc to find matched can id + if msg is None: + return None + try: + decoded = msg.decode(data) # decode into three sections + except Exception: + return None + out = { + sig.name: (decoded[sig.name], sig.unit or '') + for sig in msg.signals + if sig.name in decoded and decoded[sig.name] is not None + } # add units to parsed output (ex. v, A, C) + return (msg.name, out) if out else None + + +def _candump_reader( + interface: str, + out_queue: queue.Queue, + processes: list, +) -> None: + """ + Spawn candump on `interface`, forward each line to out_queue. + Puts a None sentinel when done so the consumer knows one source finished. + """ + try: + proc = subprocess.Popen( + ['candump', '-L', interface], + stdout=subprocess.PIPE, + text=True, + bufsize=1, + ) + except FileNotFoundError: + print( + "Error: 'candump' not found.\n" + "On the Raspberry Pi install can-utils: sudo apt install can-utils", + file=sys.stderr, + ) + out_queue.put(("error", interface)) + out_queue.put(None) + return + + processes.append(proc) + for line in proc.stdout: + out_queue.put(line) # put can message into queue + out_queue.put(None) + + +def main() -> int: + parser = argparse.ArgumentParser( + description='CAN data logger: candump -> DBC decode -> CSV + InfluxDB' + ) + + parser.add_argument( + '--all', + action='store_true', + help='Log every decodable message, not just target signals', + ) + parser.add_argument( + '--interfaces', + nargs='+', + default=['can0', 'can1'], + metavar='IFACE', + help='CAN interfaces to read from (default: can0 can1)', + ) + + args = parser.parse_args() + + script_dir = Path(__file__).resolve().parent + + veh_dbc = script_dir / 'dbc' / 'veh.dbc' + pt_dbc = script_dir / 'dbc' / 'pt.dbc' + log_dir = script_dir / 'logs' + + interfaces = args.interfaces + + if not veh_dbc.exists(): + print(f'Error: veh.dbc not found at {veh_dbc}', file=sys.stderr) + return 1 + + init_influx() + + db = load_dbc(veh_dbc, pt_dbc) + + log_path, csv_file = open_csv(log_dir) + + print(f'Logging to {log_path}', file=sys.stderr) + print(f'Interfaces: {", ".join(interfaces)}. Press Ctrl+C to stop.', file=sys.stderr) + + line_queue: queue.Queue = queue.Queue() + processes: list = [] + + # Start one CAN reader thread per interface + for iface in interfaces: + t = threading.Thread( + target=_candump_reader, + args=(iface, line_queue, processes), + daemon=True, + ) + t.start() + + def cleanup(_sig=None, _frame=None): + for p in processes: + p.terminate() + p.wait() + csv_file.close() + if client is not None: + client.close() + sys.exit(0) + + signal.signal(signal.SIGINT, cleanup) + signal.signal(signal.SIGTERM, cleanup) + + num_interfaces = len(interfaces) + done = 0 + reader_errors = 0 + + while done < num_interfaces: + line = line_queue.get() + if isinstance(line, tuple) and line[0] == "error": + reader_errors += 1 + continue + if line is None: + done += 1 + continue + + parsed = parse_candump_line(line) + if not parsed: + continue + timestamp, can_id, data = parsed + + if not args.all and can_id not in TARGET_IDS: + continue + + result = decode_frame(db, can_id, data) + if not result: + continue + + msg_name, signals = result + id_hex = f'0x{can_id:X}' + + # Build extra tags for signals that need inverter or segment identity. + extra_tags: dict = {} + if can_id in INV1_IDS: + extra_tags["inverter"] = "inv1" + elif can_id in INV2_IDS: + extra_tags["inverter"] = "inv2" + elif can_id == 406451072 and "ThermModuleNum" in signals: + extra_tags["module"] = str(int(signals["ThermModuleNum"][0])) + + for sig_name, (value, unit) in signals.items(): + csv_file.write( + f'{timestamp},{id_hex},{msg_name},{sig_name},{value},"{unit}"\n' + ) + + if sig_name in FAULT_SIGNALS and value == 1: + system, fault_desc, severity = FAULT_SIGNALS[sig_name] + write_fault_to_influx(system, fault_desc, severity, timestamp) + + if sig_name in IMPORTANT_SIGNALS: + fval = float(value) + write_to_influx(sig_name, fval, timestamp, extra_tags) + + if sig_name in SIGNAL_THRESHOLDS: + lo, hi, sys_name, desc, sev = SIGNAL_THRESHOLDS[sig_name] + checked = abs(fval) if sig_name == "Pack_Current" else fval + if (lo is not None and checked < lo) or (hi is not None and checked > hi): + write_fault_to_influx(sys_name, desc, sev, timestamp) + + csv_file.close() + if client is not None: + client.close() + return 1 if reader_errors else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/daq/main.go b/scripts/daq/main.go new file mode 100644 index 000000000..49fc945d1 --- /dev/null +++ b/scripts/daq/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "fmt" + vehcan "mac-daq/generated" + "time" + + "github.com/macformula/hil/canlink" + "go.einride.tech/can/pkg/socketcan" + "go.uber.org/zap" +) + +func main() { + can0, err := socketcan.DialContext(context.Background(), "can", "can0") + if err != nil { + panic(err) + } + + can1, err := socketcan.DialContext(context.Background(), "can", "can1") + if err != nil { + panic(err) + } + + logger, _ := zap.NewDevelopment() + + heartbeat := NewHeartbeatHandler(can0, can1, logger) + + telemetry, err := NewTelemetryHandler("./can_cache.sqlite") + if err != nil { + panic(err) + } + + daqCan0 := NewDaqHandler(vehcan.Messages(), telemetry, "can0", logger) + daqCan1 := NewDaqHandler(vehcan.Messages(), telemetry, "can1", logger) + + manager0 := canlink.NewBusManager(logger, &can0) + manager0.Register(daqCan0) + manager0.Start(context.Background()) + + manager1 := canlink.NewBusManager(logger, &can1) + manager1.Register(daqCan1) + manager1.Start(context.Background()) + + uploadTimer := time.NewTimer(time.Second) + + heartbeatInterval := time.NewTicker(3 * time.Second) + defer heartbeatInterval.Stop() + + for { + select { + case <-uploadTimer.C: + err = telemetry.Upload() + if err != nil { + fmt.Printf("failed to upload telemetry data: %v\n", err) + } + case <-heartbeatInterval.C: + err = heartbeat.SendHeartbeat() + if err != nil { + logger.Error("Failed to send heartbeat", zap.Error(err)) + } + } + } +} diff --git a/scripts/daq/requirements.txt b/scripts/daq/requirements.txt new file mode 100644 index 000000000..3f227b69e --- /dev/null +++ b/scripts/daq/requirements.txt @@ -0,0 +1,4 @@ +cantools>=39.0.0 +influxdb-client>=1.50.0 +python-dotenv>=1.2.2 +python-can>=4.6.1 \ No newline at end of file diff --git a/scripts/daq/setup.sh b/scripts/daq/setup.sh new file mode 100644 index 000000000..a1b9a9acd --- /dev/null +++ b/scripts/daq/setup.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# DAQ setup script for Raspberry Pi (ARM64) or amd64 Linux. +# Installs InfluxDB 2.x and Grafana natively — no Docker. +# Run once on a fresh system: bash setup.sh + +set -e + +echo "=== MAC Formula DAQ Setup ===" +echo "" + +# ── Detect architecture ─────────────────────────────────────────────────────── +ARCH=$(dpkg --print-architecture) +echo "Detected architecture: $ARCH" +echo "" + +# ── System packages ─────────────────────────────────────────────────────────── +echo "[1/6] Installing system packages..." +sudo apt update -q +sudo apt install -y \ + python3 \ + python3-venv \ + python3-pip \ + can-utils \ + curl \ + gnupg2 + +# ── InfluxDB 2.x ────────────────────────────────────────────────────────────── +echo "[2/6] Installing InfluxDB 2.x..." + +sudo rm -f /etc/apt/sources.list.d/influxdata.list +sudo rm -f /usr/share/keyrings/influxdata-archive-keyring.gpg + +curl -fsSL https://repos.influxdata.com/influxdata-archive_compat.key \ + | gpg --dearmor \ + | sudo tee /usr/share/keyrings/influxdata-archive-keyring.gpg > /dev/null + +echo "deb [signed-by=/usr/share/keyrings/influxdata-archive-keyring.gpg] \ +https://repos.influxdata.com/debian stable main" \ + | sudo tee /etc/apt/sources.list.d/influxdata.list + +sudo apt update -q +sudo apt install -y influxdb2 + +sudo systemctl enable --now influxdb +echo " InfluxDB running at http://localhost:8086" + +# ── Grafana ─────────────────────────────────────────────────────────────────── +echo "[3/6] Installing Grafana..." + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ "$ARCH" = "arm64" ]; then + DEB_FILE="$SCRIPT_DIR/grafana_11.0.0_arm64.deb" + + if [ ! -f "$DEB_FILE" ]; then + echo "ERROR: ARM64 Grafana .deb not found: $DEB_FILE" + exit 1 + fi + + # dpkg installs the package; apt install -f resolves any missing dependencies + sudo dpkg -i "$DEB_FILE" || true + sudo apt install -f -y + +elif [ "$ARCH" = "amd64" ]; then + sudo apt install -y software-properties-common + sudo mkdir -p /etc/apt/keyrings/ + + curl -fsSL https://apt.grafana.com/gpg.key \ + | sudo gpg --dearmor -o /etc/apt/keyrings/grafana.gpg + + echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" \ + | sudo tee /etc/apt/sources.list.d/grafana.list + + sudo apt update -q + sudo apt install -y grafana +else + echo "Unsupported architecture: $ARCH" + exit 1 +fi + +sudo systemctl enable --now grafana-server +echo " Grafana running at http://localhost:3000 (admin/admin)" + +# ── CAN interfaces ──────────────────────────────────────────────────────────── +echo "[4/6] Configuring CAN interfaces..." + +if ! grep -q "^can$" /etc/modules 2>/dev/null; then + echo "can" | sudo tee -a /etc/modules +fi +if ! grep -q "^can_raw$" /etc/modules 2>/dev/null; then + echo "can_raw" | sudo tee -a /etc/modules +fi + +sudo ip link set can0 up type can bitrate 500000 2>/dev/null \ + && echo " can0 up at 500 kbit/s" \ + || echo " can0 not available (OK if running without hardware)" + +sudo ip link set can1 up type can bitrate 500000 2>/dev/null \ + && echo " can1 up at 500 kbit/s" \ + || echo " can1 not available (OK for single-bus setup)" + +# ── Python environment ──────────────────────────────────────────────────────── +echo "[5/6] Setting up Python virtual environment..." + +cd "$SCRIPT_DIR" +python3 -m venv venv +source venv/bin/activate +pip install --upgrade pip --quiet +pip install -r requirements.txt --quiet +deactivate +echo " venv created. Activate with: source $SCRIPT_DIR/venv/bin/activate" + +# ── Final instructions ──────────────────────────────────────────────────────── +echo "" +echo "[6/6] Setup complete!" +echo "" +echo "Next steps:" +echo "" +echo "1. Open InfluxDB at http://localhost:8086" +echo " Create: org=macformula, bucket=macfe" +echo " Generate an All Access API token" +echo "" +echo "2. Edit $SCRIPT_DIR/.env:" +echo " INFLUX_URL=http://localhost:8086" +echo " INFLUX_TOKEN=" +echo " INFLUX_ORG=macformula" +echo " INFLUX_BUCKET=macfe" +echo "" +echo "3. Open Grafana at http://localhost:3000 (admin/admin)" +echo " Import grafana_dashboard.json" +echo "" +echo "4. Run the logger:" +echo " source $SCRIPT_DIR/venv/bin/activate" +echo " python $SCRIPT_DIR/logger.py" +echo "" diff --git a/scripts/daq/setup_pi.sh b/scripts/daq/setup_pi.sh new file mode 100644 index 000000000..0339616a1 --- /dev/null +++ b/scripts/daq/setup_pi.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# DAQ setup for Raspberry Pi — cloud deployment. +# Installs only what logger.py needs: Python env + CAN interfaces. +# InfluxDB and Grafana run in the cloud, not on the Pi. +# Run once on a fresh Pi: bash setup_pi.sh + +set -e + +echo "=== MAC Formula DAQ Pi Setup ===" +echo "" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── System packages ─────────────────────────────────────────────────────────── +echo "[1/3] Installing system packages..." +sudo apt update -q +sudo apt install -y python3 python3-venv python3-pip can-utils + +# ── CAN interfaces ──────────────────────────────────────────────────────────── +echo "[2/3] Configuring CAN interfaces..." + +if ! grep -q "^can$" /etc/modules 2>/dev/null; then + echo "can" | sudo tee -a /etc/modules +fi +if ! grep -q "^can_raw$" /etc/modules 2>/dev/null; then + echo "can_raw" | sudo tee -a /etc/modules +fi + +sudo ip link set can0 up type can bitrate 500000 2>/dev/null \ + && echo " can0 up at 500 kbit/s" \ + || echo " can0 not available (OK if running without hardware)" + +sudo ip link set can1 up type can bitrate 500000 2>/dev/null \ + && echo " can1 up at 500 kbit/s" \ + || echo " can1 not available (OK for single-bus setup)" + +# ── Python environment ──────────────────────────────────────────────────────── +echo "[3/3] Setting up Python virtual environment..." + +cd "$SCRIPT_DIR" +python3 -m venv venv +source venv/bin/activate +pip install --upgrade pip --quiet +pip install -r requirements.txt --quiet +deactivate +echo " venv created." + +# ── Done ────────────────────────────────────────────────────────────────────── +echo "" +echo "=== Setup complete ===" +echo "" +echo "Next steps:" +echo "" +echo "1. Copy .env to $SCRIPT_DIR/.env with your InfluxDB Cloud credentials:" +echo " INFLUX_URL=https://us-east-1-1.aws.cloud2.influxdata.com" +echo " INFLUX_TOKEN=" +echo " INFLUX_ORG=macformula" +echo " INFLUX_BUCKET=macfe" +echo "" +echo "2. Run the logger:" +echo " source $SCRIPT_DIR/venv/bin/activate" +echo " python $SCRIPT_DIR/logger.py" +echo "" diff --git a/scripts/daq/setup_vm.sh b/scripts/daq/setup_vm.sh new file mode 100644 index 000000000..264a8ecbd --- /dev/null +++ b/scripts/daq/setup_vm.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# DAQ VM test setup — verify logger.py on an amd64 Linux VM. +# Does NOT install InfluxDB or Grafana; logger uses cloud credentials from .env. +# Requires: Ubuntu 22.04+ in VirtualBox or VMware (NOT WSL2 — needs vcan kernel module). +# Run with: bash setup_vm.sh + +set -e + +echo "=== MAC Formula DAQ VM Test Setup ===" +echo "" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── System packages ─────────────────────────────────────────────────────────── +echo "[1/3] Installing system packages..." +sudo apt update -q +sudo apt install -y python3 python3-venv python3-pip can-utils + +# ── Python environment ──────────────────────────────────────────────────────── +echo "[2/3] Setting up Python virtual environment..." +cd "$SCRIPT_DIR" +python3 -m venv venv +source venv/bin/activate +pip install --upgrade pip --quiet +pip install -r requirements.txt --quiet +deactivate +echo " venv ready. Activate with: source $SCRIPT_DIR/venv/bin/activate" + +# ── Virtual CAN interfaces ──────────────────────────────────────────────────── +echo "[3/3] Setting up virtual CAN interfaces..." +sudo modprobe vcan +sudo ip link add dev vcan0 type vcan 2>/dev/null || true +sudo ip link set vcan0 up +sudo ip link add dev vcan1 type vcan 2>/dev/null || true +sudo ip link set vcan1 up +echo " vcan0 and vcan1 are up" + +# ── Done ────────────────────────────────────────────────────────────────────── +echo "" +echo "=== Setup complete ===" +echo "" +echo "Run the logger (writes to InfluxDB Cloud via .env credentials):" +echo "" +echo " source $SCRIPT_DIR/venv/bin/activate" +echo " python $SCRIPT_DIR/logger.py --interfaces vcan0 vcan1" +echo "" +echo "In a separate terminal, inject test CAN frames:" +echo "" +echo " # Random frames (exercises CSV logging, won't trigger InfluxDB writes):" +echo " cangen vcan0 -g 50 &" +echo " cangen vcan1 -g 50 &" +echo "" +echo " # Specific frames matching DBC IDs (triggers InfluxDB writes too):" +echo " cansend vcan0 624#0000000000000000 # Pack_State (voltage, current)" +echo " cansend vcan0 625#0000000000000000 # Pack_SOC" +echo " cansend vcan0 283#0000000000000000 # Inv1_ActualValues1 (RPM)" +echo "" +echo "Verify:" +echo " - CSV log files appear in: $SCRIPT_DIR/logs/" +echo " - InfluxDB Cloud bucket 'macfe' receives data (check .env for URL)" +echo "" diff --git a/scripts/daq/telemetry.go b/scripts/daq/telemetry.go new file mode 100644 index 000000000..e7754ffc8 --- /dev/null +++ b/scripts/daq/telemetry.go @@ -0,0 +1,201 @@ +package main + +import ( + "container/list" + "database/sql" + "fmt" + + "github.com/macformula/hil/canlink" + "go.einride.tech/can" + "go.uber.org/zap" + _ "modernc.org/sqlite" +) + +const BufferSize = 512 + +type TelemetryPacket struct { + Id int64 `db:"id"` + Timestamp int64 `db:"timestamp"` + FrameId uint32 `db:"frame_id"` + FrameData can.Data `db:"frame_data"` +} + +type TelemetryHandler struct { + buf *list.List + db *sql.DB + l *zap.Logger + dbFile string +} + +func NewTelemetryHandler(dbFile string) (*TelemetryHandler, error) { + db, err := sql.Open("sqlite", dbFile) + if err != nil { + return nil, err + } + + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS can_cache ( + id INTEGER PRIMARY KEY, + timestamp INTEGER, + frame_id INTEGER, + frame_data INTEGER, + bus_name TEXT + ) + `) + if err != nil { + db.Close() + return nil, err + } + + _, err = db.Exec("CREATE INDEX IF NOT EXISTS idx_timestamp ON can_cache(timestamp)") + if err != nil { + db.Close() + return nil, err + } + + t := TelemetryHandler{ + buf: list.New(), + db: db, + } + + err = t.fillBufferFromDisk() + if err != nil { + db.Close() + return nil, err + } + + fmt.Printf("Initial buffer size: %v\n", t.buf.Len()) + + return &t, nil +} + +func (t *TelemetryHandler) Enqueue(frame canlink.TimestampedFrame, busName string) error { + query, err := t.db.Prepare(` + INSERT INTO can_cache (timestamp, frame_id, frame_data, bus_name) VALUES (?, ?, ?, ?) + `) + if err != nil { + return err + } + + res, err := query.Exec(frame.Time.UnixMilli(), frame.Frame.ID, frame.Frame.Data.PackBigEndian(), busName) + if err != nil { + return err + } + + // Ignore the error since the schema enforces an auto-incrementing primary key + id, _ := res.LastInsertId() + + elem := list.Element{ + Value: TelemetryPacket{ + Id: id, + Timestamp: frame.Time.UnixMilli(), + FrameId: frame.Frame.ID, + FrameData: frame.Frame.Data, + }, + } + + if t.buf.Len() >= BufferSize { + // go upload ? + t.buf.Remove(t.buf.Back()) + } + t.buf.MoveToFront(&elem) + + return nil +} + +func (t *TelemetryHandler) Upload() error { + // pop from front of buffer and write to backend + + // Ignore for now ... + /*buf := t.emptyBuffer() + + type temp struct { + Value []byte `json:"value"` + Timestamp int64 `json:"timestamp"` + } + + for i := range len(buf) { + frame := can.Frame{ + ID: buf[i].FrameId, + Data: buf[i].FrameData, + Length: 8, + } + rawFrame, _ := json.Marshal(frame) + + toWrite := temp{ + Timestamp: buf[i].Timestamp, + Value: rawFrame, + } + + data, err := json.Marshal(toWrite) + if err != nil { + return err + } + if i == 0 { + fmt.Printf("payload: %s\n", string(data)) + } + + // If this fails then we should re-queue the entire buffer to not lose anything + _, err = http.Post("http://localhost:5000/write-graph", "application/json", bytes.NewBuffer(data)) + if err != nil { + fmt.Printf("telemetry/upload: failed to post frame data: %v\n", err) + return err + } + } + + // In case of an error here, just log and keep going, we still need to upload our data + err := t.fillBufferFromDisk() + if err != nil { + fmt.Printf("telemetry/upload: failed to fill buffer: %v\n", err) + }*/ + + return nil +} + +func (t *TelemetryHandler) emptyBuffer() []TelemetryPacket { + packets := make([]TelemetryPacket, t.buf.Len()) + next := t.buf.Front() + + for range t.buf.Len() { + packets = append(packets, next.Value.(TelemetryPacket)) + next = next.Next() + } + + return packets +} + +func (t *TelemetryHandler) fillBufferFromDisk() error { + t.buf.Init() + + query, err := t.db.Prepare(` + SELECT id, timestamp, frame_id, frame_data FROM can_cache ORDER BY timestamp DESC LIMIT ? + `) + if err != nil { + return err + } + + rows, err := query.Query(BufferSize) + if err != nil { + return err + } + + for rows.Next() { + var packet TelemetryPacket + var packedFrameData uint64 + + err = rows.Scan(&packet.Id, &packet.Id, &packet.FrameId, &packedFrameData) + if err != nil { + fmt.Printf("Failed to scan packet: %v\n", err) + continue + } + + var frameData can.Data + frameData.UnpackBigEndian(packedFrameData) + packet.FrameData = frameData + + t.buf.PushBack(packet) + } + + rows.Close() + return nil +} diff --git a/scripts/daq/test_encode.py b/scripts/daq/test_encode.py new file mode 100644 index 000000000..dbad45d7b --- /dev/null +++ b/scripts/daq/test_encode.py @@ -0,0 +1,20 @@ +import cantools + +db = cantools.database.load_file("dbc/veh.dbc") + +msg = db.get_message_by_name("DashCommand") + +data = msg.encode({ + "Speed": 30, + "HvSocPercent": 50, + "ConfigReceived": 1, + "HvStarted": 1, + "MotorStarted": 1, + "DriveStarted": 1, + "Errored": 0, + "HvPrechargePercent": 50 +}) + +print(data.hex()) + + diff --git a/scripts/daq/test_influx.py b/scripts/daq/test_influx.py new file mode 100644 index 000000000..372f2688f --- /dev/null +++ b/scripts/daq/test_influx.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +Simulates CAN signal data being written to InfluxDB. +Run this to verify the Python to InfluxDB connection without needing a CAN bus. +Uses the same measurement/tag/field structure as logger.py. +""" + +import os +import time +import math +import random +import urllib3 +from dotenv import load_dotenv +from influxdb_client import InfluxDBClient, Point +from influxdb_client.client.write_api import SYNCHRONOUS + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +load_dotenv(override=True) + +client = InfluxDBClient( + url=os.getenv("INFLUX_URL"), + token=os.getenv("INFLUX_TOKEN"), + org=os.getenv("INFLUX_ORG"), + verify_ssl=False, +) +write_api = client.write_api(write_options=SYNCHRONOUS) +bucket = os.getenv("INFLUX_BUCKET") + + +def write_signal(sig_name, value): + point = ( + Point("car_data") + .tag("signal", sig_name) + .field("value", float(value)) + ) + write_api.write(bucket=bucket, record=point) + + +def write_fault(system, fault, severity): + point = ( + Point("faults") + .tag("system", system) + .tag("fault", fault) + .tag("severity", severity) + .field("active", 1) + ) + write_api.write(bucket=bucket, record=point) + + +def main(): + print(f"Connecting to InfluxDB at {os.getenv('INFLUX_URL')} ...") + print(f"Org: {os.getenv('INFLUX_ORG')} Bucket: {bucket}\n") + + # Verify connection with a test write + try: + test_point = Point("car_data").tag("signal", "_test").field("value", 0.0) + write_api.write(bucket=bucket, record=test_point) + print("Connection OK — test write succeeded.\n") + except Exception as e: + print(f"ERROR: Could not write to InfluxDB — {e}") + print("Check your token, org, bucket, and URL in .env") + return + + print("Writing simulated CAN signals (Ctrl+C to stop)...\n") + + t = 0 + try: + while True: + # Simulate values using actual DBC signal names so the dashboard queries match. + inv1_rpm = 2000 + 1500 * math.sin(t * 0.3) + random.uniform(-50, 50) + inv2_rpm = 1800 + 1400 * math.sin(t * 0.3 + 0.2) + random.uniform(-50, 50) + # Pack voltage: 360V min (cell damage), 600V max; simulate normal discharge ~490V + battery_v = 490 + 15 * math.sin(t * 0.05) + random.uniform(-1, 1) + soc = max(20, 90 - t * 0.2 + random.uniform(-0.5, 0.5)) + # Temps warm up from ambient; stay well below 70°C motor / 60°C inverter limits + motor_temp = 30 + 18 * (1 - math.exp(-t * 0.02)) + random.uniform(-1, 1) + inverter_temp = 28 + 14 * (1 - math.exp(-t * 0.015)) + random.uniform(-1, 1) + igbt_temp = inverter_temp + random.uniform(-2, 2) + # Pack current: fuse rated 60A, absolute max 59A; simulate normal load ~10–45A + pack_current = 27 + 18 * math.sin(t * 0.4) + random.uniform(-3, 3) + speed = 60 + 25 * math.sin(t * 0.05) + random.uniform(-2, 2) + apps1 = max(0, min(100, 50 + 45 * math.sin(t * 0.2) + random.uniform(-2, 2))) + apps2 = max(0, min(100, apps1 + random.uniform(-3, 3))) + bpps = max(0, min(100, 20 * abs(math.sin(t * 0.15)) + random.uniform(-1, 1))) + # LV battery: 18V min, 29V fully charged; simulate ~24V nominal system + lv_batt_v = 24.5 + 1.5 * math.sin(t * 0.02) + random.uniform(-0.1, 0.1) + lv_batt_temp = 25 + 8 * (1 - math.exp(-t * 0.01)) + random.uniform(-0.5, 0.5) + + # Scalar signals (no extra tags needed) + for name, value in [ + ("Pack_Inst_Voltage", battery_v), + ("Pack_Current", pack_current), + ("Pack_SOC", soc), + ("Speed", speed), + ("Apps1Percent", apps1), + ("Apps2Percent", apps2), + ("BppsPercent", bpps), + ("LvBatteryVoltage", lv_batt_v), + ("LvBatteryTemp", lv_batt_temp), + ]: + write_signal(name, value) + + # Motor signals — write once per inverter with the inverter tag + for inverter, rpm, mtemp, itemp, igbt in [ + ("inv1", inv1_rpm, motor_temp, inverter_temp, igbt_temp), + ("inv2", inv2_rpm, motor_temp + 1.5, inverter_temp + 1.0, igbt_temp + 1.0), + ]: + for sig_name, val in [ + ("ActualVelocity", rpm), + ("TempMotor", mtemp), + ("TempInverter", itemp), + ("TempIGBT", igbt), + ]: + point = ( + Point("car_data") + .tag("signal", sig_name) + .tag("inverter", inverter) + .field("value", float(val)) + ) + write_api.write(bucket=bucket, record=point) + + # Battery segment temps — 6 modules, each with High/Low/Avg + base_temp = 35 + 5 * (1 - math.exp(-t * 0.01)) + for module in range(6): + spread = module * 0.8 + random.uniform(-0.5, 0.5) + for sig_name, val in [ + ("HighThermValue", base_temp + spread + 2), + ("LowThermValue", base_temp + spread - 2), + ("AvgThermValue", base_temp + spread), + ]: + point = ( + Point("car_data") + .tag("signal", sig_name) + .tag("module", str(module)) + .field("value", float(val)) + ) + write_api.write(bucket=bucket, record=point) + + # Occasionally write a simulated fault + if t > 0 and int(t) % 15 == 0: + write_fault("CAN", "TX Error", "WARNING") + print(f" t={t:.0f}s Wrote fault: CAN / TX Error / WARNING") + if t > 0 and int(t) % 30 == 0: + write_fault("Motor", "Left Motor Running Error", "CRITICAL") + print(f" t={t:.0f}s Wrote fault: Motor / Left Motor Running Error / CRITICAL") + + print( + f" t={t:.0f}s inv1={inv1_rpm:.0f}rpm inv2={inv2_rpm:.0f}rpm " + f"v={battery_v:.1f}V soc={soc:.1f}% " + f"mtemp={motor_temp:.1f}°C curr={pack_current:.1f}A spd={speed:.1f}mph " + f"apps1={apps1:.1f}% apps2={apps2:.1f}% bpps={bpps:.1f}%" + ) + + t += 1 + time.sleep(1) + + except KeyboardInterrupt: + print("\nStopped.") + finally: + client.close() + + +if __name__ == "__main__": + main()