Build a Type-safe CAN/BMS simulator in TypeScript for EV firmware testing
Build a type-safe CAN/BMS simulator in TypeScript to accelerate EV firmware testing, HIL, and PCB bench validation.
When you’re validating EV firmware, the difference between a flaky bench setup and a trustworthy hardware-in-the-loop workflow is often the quality of your simulator. A type-safe CAN bus and BMS simulator in TypeScript gives you a practical middle ground: fast enough to iterate locally in Node, structured enough to mirror real telemetry, and flexible enough to plug into PCB test rigs. That matters because EV electronics are getting denser and more interconnected, and the underlying PCB market for EV systems continues to expand as battery management, power electronics, and connectivity modules become more complex. For a broader look at the systems side of EV electronics, it helps to understand how the board-level environment is evolving in our guide to integrating circuit identifier data into maintenance automation and the broader rise of printed circuit board demand in electric vehicles.
This guide is not about toy mocks. It’s about designing a simulator that can emit realistic frames, enforce payload contracts at compile time, model BMS state transitions, and participate in integration tests against firmware, gateway software, or even bench harnesses connected to actual PCBs. If your team has ever wished that firmware bugs could be reproduced before the hardware team burns another afternoon on wiring, this approach will feel immediately useful. It also aligns well with modern automated test thinking, similar to the rigor described in end-to-end hardware testing labs with local benchmarking and telemetry and the auditability mindset in designing auditable execution workflows.
Why simulate CAN and BMS in TypeScript at all?
Firmware teams need speed, repeatability, and realism
Firmware iteration slows down the moment a developer must wait on physical hardware to verify one message path. A TypeScript-based simulator removes that dependency for a large class of tests: startup sequences, charger negotiation, fault handling, cell balancing commands, SOC drift, and watchdog timing. Because the simulator runs in Node, you can run it locally, in CI, or inside a lab control script that also manages USB-CAN adapters and relay boards. That means your firmware team can test more often, with less setup friction, and catch regressions before they reach a PCB bench.
The important part is realism. A BMS does not behave like a single static JSON fixture; it changes state over time, reacts to thermal and voltage thresholds, and emits messages with timing dependencies. If your test doubles ignore those dynamics, your firmware may pass tests and still fail on hardware. A well-designed simulator gives you the practical realism of telemetry streams while preserving the speed of software testing, much like how modern diagnostics systems combine sensor data with structured rules in AI-assisted vehicle diagnostics.
Type safety catches protocol mistakes before they reach the bench
CAN is a contract-driven protocol. Every frame has an ID, a payload length, a byte order, scaling rules, and sometimes a strict cadence. TypeScript shines here because it lets you model those contracts explicitly. You can represent a frame schema, derive encoder and decoder types, and prevent invalid test data from compiling in the first place. That reduces the class of failures where the simulator says one thing while the firmware expects another, which is especially valuable when multiple engineers are editing signal maps and test cases in parallel.
Type safety is also a strong fit for firmware testing because it improves refactors. If a message definition changes from 8 bytes to 12, or a thermal sensor field is renamed, the compiler can tell you which fixtures, parsers, and assertions need updates. That is the same reason many teams move from ad hoc scripts to systematic test harnesses, similar to the disciplined approach recommended in stepwise refactors of legacy on-prem systems and the resilience mindset behind automated defense pipelines.
Hardware-in-the-loop becomes easier to standardize
HIL testing can become expensive when each fixture behaves slightly differently. One rig might use a relay module with 20 ms delay, another a USB interface with occasional frame bursts, and a third a manually scripted battery emulator. A TypeScript simulator helps normalize this chaos by giving you a single programmable source of truth for state transitions, timing, and error injection. You can then vary only the hardware layer while keeping the logical behavior consistent.
That consistency matters for PCB-level test rigs as EV systems gain more electronic content. As the EV PCB ecosystem grows, teams are increasingly validating battery packs, power modules, and control boards as an integrated system rather than in isolation. The broader market trend reinforces why simulation is not a “nice-to-have” but a productivity multiplier, especially for teams working on the electronics stack described in EV PCB expansion reports.
Model the CAN bus like a protocol, not a bag of bytes
Start with strongly typed frame definitions
The first design decision is to stop thinking about frames as arbitrary byte arrays. Instead, define each message as a typed schema with an ID, length, byte layout, and semantic fields. In a CAN/BMS simulator, that schema should include scaling metadata, units, and valid ranges, because firmware often depends on those conversions. A message like pack voltage should not merely be “an array of two bytes”; it should encode how raw counts map to volts, and what happens if the simulator injects an out-of-range value.
type CanId = 0x101 | 0x102 | 0x201 | 0x301;
type SignalDef = {
name: string;
startBit: number;
bitLength: number;
scale: number;
offset: number;
min?: number;
max?: number;
};
type FrameDef<TName extends string, TId extends CanId> = {
name: TName;
id: TId;
dlc: 8 | 12 | 16;
signals: readonly SignalDef[];
};Once the definitions are explicit, you can write generic encoders and decoders that operate on them. That means your test harness can emit realistic telemetry and your assertions can consume the same typed contract, reducing the risk of drift. This is especially useful when the CAN map evolves alongside firmware and PCB revisions, which is common in EV programs where the board and firmware teams ship on different cadences.
Use discriminated unions for message families
Different frame families deserve different types. Status frames, command frames, and fault frames should not share a vague “message” interface if they behave differently. Discriminated unions make it simple to model this distinction and keep branch logic safe. For example, a charger command frame may require a current setpoint, while a fault frame may carry a fault code and a severity level.
This structure helps when you build test cases that validate firmware behavior across many message types. The simulator can generate a typed stream that is impossible to misuse accidentally, and your integration tests can focus on behavior rather than parsing. If your team already values structured tooling and repeatable workflows, that same discipline appears in guides such as and the careful approach to testing and setup found in hardware telemetry lab design.
Represent timing and cadence as first-class data
CAN simulation is not only about content; it’s about timing. Real ECUs expect periodic messages at precise intervals, and watchdogs may trip if a frame stops arriving or arrives too slowly. Your TypeScript simulator should therefore track publish intervals, jitter tolerance, and startup sequencing. A heartbeat frame at 100 ms, a battery status frame at 500 ms, and a fault announcement sent immediately after a thermal threshold crosses should each have explicit timing rules.
When timing is modeled as data, tests become expressive. You can write scenarios that intentionally delay one frame by 250 ms to verify timeout handling, or suppress a frame for 2 seconds to confirm a fail-safe response. This mirrors how mature systems engineering treats operational cadence as part of the contract, not an incidental detail.
Model BMS behavior as a state machine
Define operational states clearly
A BMS is fundamentally a stateful system. It tracks pack voltage, cell voltages, current, temperature, relay status, isolation state, charger state, and fault flags. The simulator should model operational states such as Idle, Precharge, Active, Charging, Balancing, Fault, and Shutdown. Each state should have entry conditions, exit conditions, and message emissions tied to that state.
The benefit of a state machine is that it makes transitions testable. For example, if pack temperature exceeds a safe threshold, the simulator can move from Active to Fault, assert a thermal fault code, and stop emitting normal operating frames. That gives firmware a realistic failure mode to respond to. For teams interested in designing related flows with clear state transitions, the same thinking appears in auditable execution workflows and stepwise modernization strategies.
Make transitions event-driven
Instead of hardcoding the simulator in a giant loop, drive it with events: voltage changes, contactor commands, charge requests, thermal spikes, and cell imbalance drift. Each event can update the internal BMS state, then trigger frame emissions. This gives you reproducibility and scenario control. A test can say “raise cell 7 above limit after 18 seconds” and then assert that the firmware responds within one message cycle.
Pro Tip: Keep the event log as important as the frame log. In HIL debugging, the question is rarely “what did the simulator send?” alone; it is “why did the simulator enter this state at this time?” A chronological event trail makes failures much faster to diagnose.
Event-driven design also makes it easy to inject faults. You can simulate a stuck temperature sensor, an overcurrent event, a relay weld, or a noisy ADC reading without touching the rest of the state machine. That is one reason simulation scales better than handcrafted bench scripts as the test matrix grows.
Separate physics approximation from protocol behavior
Not every simulator needs a full electrochemical model of a battery pack. For firmware testing, a practical approximation is usually enough: bounded temperature drift, SOC changes based on charge and discharge current, and cell voltage sag under load. Keep the physics lightweight and deterministic, while the protocol layer remains strict and type-safe. The goal is to help firmware teams validate behavior, not to build a battery research platform.
This separation lets you evolve the simulator in layers. Start with protocol fidelity, then add thermal and SOC heuristics, and later introduce more detailed battery behavior if a specific project demands it. That staged approach is similar in spirit to how organizations adopt new systems incrementally, as seen in legacy modernization playbooks.
Build the simulator architecture in Node/TypeScript
Core modules you actually need
A production-grade simulator usually needs five pieces: a protocol model, a BMS state engine, a scheduler, a transport adapter, and a test API. The protocol model defines frames and signals. The state engine decides what the virtual battery is doing. The scheduler determines when frames should be emitted. The transport adapter bridges the simulator to real interfaces such as SocketCAN, USB-CAN, or a mock transport in CI. The test API gives engineers a clean way to script scenarios.
Keeping these modules separate prevents the codebase from collapsing into a single “simulate()” function that nobody wants to maintain. It also lets you run the simulator in different contexts. In local development, you might use a pure in-memory transport. In a lab, you might emit to actual CAN hardware connected to a PCB test rig. And in CI, you can replay recorded message logs to validate firmware parsing logic on every commit.
Use a transport abstraction to bridge software and hardware
Transport abstraction is where the simulator becomes truly useful for HIL. A simple interface like send(frame) and onFrame(handler) allows the same logic to communicate with a real CAN interface or a mock. With that abstraction in place, you can drive an actual bench setup through a USB-CAN adapter while still using the same scenario definitions that run in CI.
This is especially important when testing PCB-integrated systems. Your bench may include relays, power supplies, sensors, and a firmware target board, but the source of truth for message sequencing should still be code. The same approach is useful in other real-time systems where telemetry and control loops must be consistent, much like the telemetry-first philosophy in hardware benchmarking labs and the automated monitoring mindset in defense pipelines.
Persist scenarios as code, not screenshots
If your simulator scenarios live in code, they are versioned, reviewable, and reproducible. You can describe a startup sequence, a charging profile, or a fault injection test in TypeScript, then run it the same way on a developer laptop or a lab machine. This is much more maintainable than keeping ad hoc terminal scripts or manual oscilloscope notes. It also makes it easier to add regression tests when firmware behavior changes.
For teams managing shared tooling, this code-first approach echoes the value of well-governed software stacks and structured vendor selection. A similar mindset shows up in resources like procurement questions for enterprise software, because long-term maintainability beats flashy point solutions.
Example implementation: a minimal type-safe BMS simulator
Frame and state definitions
Below is a compact example showing the shape of a TypeScript BMS simulator. The goal is not completeness; it is to demonstrate how type-safe contracts and a state machine fit together. In real use, you would expand the frame definitions and add encoding helpers for Intel/Motorola bit order, checksum logic, and more detailed timing.
type BmsState = 'idle' | 'precharge' | 'active' | 'charging' | 'fault' | 'shutdown';
type ThermalFault = 'none' | 'sensor_open' | 'overtemp' | 'undertemp';
type PackSnapshot = {
packVoltageMv: number;
packCurrentMa: number;
socPct: number;
cellTempsC: number[];
cellVoltagesMv: number[];
contactorClosed: boolean;
thermalFault: ThermalFault;
};
type BmsEvent =
| { type: 'boot' }
| { type: 'enable' }
| { type: 'charge_request'; currentMa: number }
| { type: 'load_step'; deltaMa: number }
| { type: 'temp_rise'; sensor: number; deltaC: number }
| { type: 'fault'; code: ThermalFault };
class BmsSimulator {
private state: BmsState = 'idle';
private snapshot: PackSnapshot = {
packVoltageMv: 384000,
packCurrentMa: 0,
socPct: 72,
cellTempsC: [24, 24, 25, 24, 25, 24, 24, 24],
cellVoltagesMv: [4020, 4018, 4021, 4019, 4022, 4017, 4020, 4018],
contactorClosed: false,
thermalFault: 'none'
};
dispatch(event: BmsEvent) {
// transition logic here
}
}This skeleton is useful because it keeps domain terms in the type system. Engineers reading the code immediately understand what the simulator can represent, and tests can use the same domain vocabulary. That consistency is one of the strongest advantages of TypeScript in embedded-adjacent tooling: it reduces ambiguity in a space where ambiguity often turns into wasted bench time.
Frame encoding and decoding
Next, define a codec layer that turns snapshot fields into CAN frames. For example, one frame can carry pack voltage, pack current, and SOC, while another carries min/max cell voltage and temperature extremes. The encoder should clamp values to valid ranges and raise errors when a test intentionally pushes invalid data. The decoder should be used both by the simulator and by assertions in integration tests so you aren’t duplicating protocol knowledge.
That shared codec layer is what allows your HIL rig to remain trustworthy. If a firmware engineer changes a signal scaling rule, the compiler and tests should fail together, instead of letting a mismatch leak into the bench. This is the kind of practical reliability that makes tooling valuable for fast-moving EV teams.
Scenario runners for repeatable tests
Write scenario runners that accept a sequence of events and timing offsets. A startup scenario might boot the BMS, assert precharge, transition to active, and then begin periodic telemetry. A charge scenario might simulate charger negotiation, update current limits, and validate that the firmware honors the advertised charging envelope. A fault scenario might introduce a sensor open circuit and verify that the target board reacts appropriately.
Scenario runners are also the place to record artifacts: emitted frames, timestamps, fault logs, and state transitions. Those artifacts become invaluable when a failure appears only on the bench and not in a quick unit test. With enough discipline, you can even replay production-like logs against a new firmware build to check for regressions before hardware signoff.
How to wire the simulator into hardware-in-the-loop tests
Connect Node to real hardware cleanly
In a lab, the simulator typically sits between test code and the hardware under test. A common setup includes a Node process running the simulator, a CAN interface connected to the PCB under test, and a power or relay controller handling resets and fault injection. The test harness can then orchestrate boot, communication checks, charging sequences, and failure scenarios without requiring manual intervention. This turns the bench into a programmable environment instead of a scripted one-off setup.
For teams wanting a broader operational model, this is similar to how modern test labs centralize telemetry and control. The same discipline appears in guides about local hardware benchmarking and even in the more general idea of automating circuit identification for maintenance. The common thread is control plus observability.
Pair firmware assertions with simulator telemetry
Integration tests should not only confirm that firmware “did not crash.” They should check message content, cadence, and state progression. For example, after a simulated overtemperature event, the test should assert that a fault frame is emitted within a specified interval, that the contactor command is opened, and that normal drive frames stop. These checks are strong because they validate end-to-end behavior rather than isolated functions.
The simulator’s telemetry stream can also be used to drive dashboards. If you surface recent events, emitted frames, and state transitions in a machine-readable format, your lab operators can spot trends across runs. This makes the system more than a test tool; it becomes a visibility layer for firmware quality.
Integrate with PCB test rigs and fixture automation
PCB test rigs benefit from repeatable protocol emulation because they often need the same boot and handshake steps across many boards. Instead of manually coaxing a board into the right state, the simulator can provide the expected CAN traffic while the fixture validates relay closures, sensor presence, or power draw. That reduces variability and speeds up throughput on a manufacturing or validation bench.
This is where the market dynamics matter again. As EV PCB complexity grows and more systems depend on battery control and telemetry, teams need faster ways to validate board behavior under realistic conditions. The rising demand for advanced EV board technologies described in EV PCB market analysis is exactly why structured simulation is worth the engineering investment.
Testing strategy: unit, integration, and fault-injection layers
Unit tests for the protocol layer
Unit tests should validate your codecs, scaling rules, and schema constraints. These are the cheapest and fastest checks, and they should cover edge cases such as maximum values, invalid byte lengths, and rounding behavior. If a signal scales in 10 mV increments, test the rounding boundaries. If a frame requires a fixed DLC, test that all shorter payloads are rejected. Unit tests are where TypeScript pays off most quickly, because type errors and runtime contract errors can be separated cleanly.
Integration tests for real firmware interaction
Integration tests should prove that the firmware and simulator agree on timing and semantics. Use them to validate startup, charging, balancing, sleep wakeup, and fault handling. These tests should run against the same simulator API you use in local development, but connected to actual hardware or a more complete board rig. A solid integration suite prevents the “works on my machine” problem from becoming a “fails only in the lab” problem.
Fault injection for resilience
Fault injection is where a good simulator becomes a great one. Simulate missing frames, corrupted values, delayed responses, sensor drift, low-voltage events, and relay inconsistencies. Then verify that firmware degrades gracefully. In EV systems, safe failure is not optional, so your simulator should actively help you test the conditions that matter most. This is the same kind of stress-oriented thinking that appears in automated defense pipelines and diagnostic AI workflows, where resilience is designed, not assumed.
| Testing layer | What it verifies | Best tool fit | Failure it catches | Typical runtime |
|---|---|---|---|---|
| Unit | Encoding, decoding, scaling | TypeScript test runner | Schema drift, bad math | Seconds |
| Component | State machine transitions | Node simulator | Bad event handling | Seconds to minutes |
| Integration | Firmware communication | Simulator + CAN adapter | Timeouts, protocol mismatches | Minutes |
| HIL | Board behavior under load | Simulator + PCB rig | Power sequencing, relay issues | Minutes to hours |
| Fault injection | Safety response | Scenario runner | Missing fail-safes | Minutes |
Practical lab setup: from laptop to bench
Local development mode
In local mode, the simulator should run entirely in memory and stream frames to your test process. This gives developers a quick feedback loop and lets them write integration-like tests without hardware attached. You can also record traces from the simulator to compare expected and actual behavior over time. This is especially useful when developers are working remotely or when the bench is occupied.
Bench mode with adapters and relays
Bench mode adds real interfaces. You may use a CAN adapter for the bus, relay control for power cycling, and scripts for fixture state changes. In this mode, the simulator becomes a programmable bench partner rather than a pure software artifact. The key is to keep the same test scenarios intact while swapping transport details. That way, a scenario that passed locally can be executed on the rig with only environmental differences.
For complex lab ecosystems, it helps to treat the bench like any other production system: versioned configs, observable logs, and auditable operations. That mindset matches the reliability themes in auditable workflows and the maintainability discipline of stepwise refactors.
CI mode with recorded traces
Not every integration test needs hardware. In CI, you can replay frame logs captured from real devices or from validated bench runs. This gives you a stable regression suite that still reflects real protocol behavior. Over time, you can build a library of “known-good” traces for boot, charge, sleep, and fault paths, then use them as fixtures for firmware parsing tests and dashboard validation.
CI mode is also where hidden regressions surface early. If a developer changes a timer or alters a frame field, the test suite can flag the mismatch before it reaches the lab. That saves hardware wear, bench time, and scheduling overhead.
Observability, telemetry, and debugging
Log state transitions and frame deltas
Good observability turns a simulator from a black box into a teaching tool. Log every state transition, frame emission, and injected event with timestamps. When a test fails, the first question is usually whether the simulator behaved as expected. A structured log makes the answer obvious. Better yet, include frame deltas so engineers can see what changed from one emission to the next.
Build dashboards for test runs
Dashboards help teams spot patterns across dozens or hundreds of runs. If a certain firmware build consistently delays a fault response, a chart can show that trend before the issue becomes a release blocker. This is where telemetry becomes more than debugging data; it becomes an engineering signal. The same philosophy appears in telemetry-centric workflows elsewhere, including the approach described in telemetry-based hardware labs.
Make failures explain themselves
The best simulator failures are self-explanatory. If the firmware timed out waiting for a precharge completion frame, the test output should show which frame was last sent, which state the BMS was in, and what event caused the stall. That reduces the round-trip from failure to diagnosis. In practice, this is one of the biggest returns on investment in simulator work: fewer ambiguous failures, faster root cause analysis, and shorter debug cycles.
Common pitfalls and how to avoid them
Over-modeling the battery
It is tempting to build a perfect battery model, but most firmware testing does not need one. Focus on the parts of the physics that influence protocol behavior: SOC progression, thermal thresholds, voltage sag, and recovery after load changes. Over-modeling adds complexity without necessarily increasing test value. Keep the first version small, deterministic, and easy to reason about.
Letting types drift from the real protocol
If your TypeScript types diverge from the firmware’s actual CAN definitions, you will create false confidence. Protect against drift by generating types from a canonical source when possible, or by validating the simulator against real traces regularly. The contract should live as close as possible to the source of truth. This is especially important in programs where firmware, PCB revisions, and supplier changes all move at once.
Ignoring timing and jitter
Many “correct” simulators fail because they ignore timing behavior. If the firmware expects a heartbeat every 100 ms and the simulator emits it every 500 ms in tests, the test is not representative. Likewise, too much jitter can hide race conditions. Treat timing as part of the message definition and make it configurable so you can test both nominal and degraded conditions.
Pro Tip: Build one canonical scenario that mirrors your most failure-prone bench case, then keep it in CI forever. The scenario may not be glamorous, but it becomes your fastest detector of regressions in firmware, wiring, or message mapping.
Roadmap: from simulator to full EV test platform
Add packet capture and replay
Once the simulator is stable, add capture and replay tooling so you can ingest real bus traces and convert them into regression fixtures. This closes the loop between bench observations and software tests. You can turn an elusive hardware bug into a repeatable software scenario, which is often the difference between a multi-day mystery and a one-hour fix.
Extend to multi-ECU and gateway tests
Real EV systems are multi-node. After the BMS simulator works well, extend the architecture to include charger, inverter, gateway, and dashboard modules. Each node can have its own TypeScript model and transport adapter. This lets you test system interactions, not just isolated BMS behavior, and makes your lab much more representative of vehicle reality.
Use the simulator as a product asset
Well-built simulators do more than support one project. They become reusable assets across firmware teams, manufacturing validation, supplier qualification, and regression testing. That reusable value is exactly why simulation deserves engineering investment. As EV electronics and PCB complexity continue to rise, teams that build robust test infrastructure will move faster and ship with more confidence, much like organizations that invest in durable systems rather than one-off fixes.
For teams evaluating broader operational and platform changes, it can be useful to remember the same principles found in software procurement discipline and hardware lab telemetry: standardize the interface, instrument the flow, and keep the system observable.
Frequently asked questions
Can I build a useful CAN/BMS simulator without a full battery physics model?
Yes. Most firmware tests only need realistic protocol behavior, timing, and state transitions. A bounded approximation of SOC, temperature drift, and voltage sag is usually enough. You can always add more detailed battery behavior later if a specific test requires it.
Why use TypeScript instead of plain JavaScript for this kind of simulator?
TypeScript gives you compile-time validation for frame shapes, state transitions, event payloads, and encoder/decoder contracts. That matters when many engineers share the simulator or when protocol definitions change frequently. The compiler catches drift early and makes refactors safer.
How do I connect the simulator to real hardware-in-the-loop benches?
Use a transport abstraction. Keep your simulation logic separate from the CAN adapter, then plug in a real interface such as USB-CAN or SocketCAN for bench mode. The same scenario code can run locally, in CI, or against actual PCB rigs with minimal changes.
What should I test first in a BMS simulator?
Start with boot, precharge, telemetry publishing, and basic fault handling. Those flows tend to expose message mapping problems, timing issues, and startup sequencing mistakes early. Once those are reliable, add charge control, balancing, and degraded-mode scenarios.
How do I keep simulator data in sync with firmware changes?
Make the CAN schema a shared source of truth, generate types where possible, and validate simulator behavior against real traces regularly. Version your scenarios and treat message definitions like code, not documentation. That reduces protocol drift and keeps the bench trustworthy.
Related Reading
- End-to-End Quantum Hardware Testing Lab: Setting Up Local Benchmarking and Telemetry - A practical model for instrumentation, repeatability, and lab observability.
- Building Better Diagnostics: Integrating Circuit Identifier Data into Maintenance Automation - Useful patterns for tying identifiers to reliable maintenance workflows.
- Modern Solutions for Vehicle Maintenance: The Role of AI in Diagnostics - A broader look at data-driven diagnosis in vehicle systems.
- Modernizing Legacy On‑Prem Capacity Systems: A Stepwise Refactor Strategy - Helpful for teams evolving lab tooling without breaking what already works.
- Designing Auditable Flows: Translating Energy‑Grade Execution Workflows to Credential Verification - Strong ideas on traceability, verification, and controlled execution.
Related Topics
Alex Mercer
Senior TypeScript & Embedded Systems Editor
Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.
Up Next
More stories handpicked for you