Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

OpenLCB Programmer’s Guide

by John Socha‑Leialoha

This edition is current as of 2025-12-23.

I wrote this book to help developers get started with OpenLCB. I used GitHub Copilot to help draft the text; during writing Copilot was provided access to the OpenMRN and OpenMRN‑Lite source code, the NMRA LCC standards, and NMRA LCC technical notes to assist with examples and explanations.

The source for this book is available at the project’s GitHub repository: openlcb/OpenLCB_Technical_Introduction.

Feedback and contributions are welcome — please open an issue or submit a pull request on the repository’s issues page: openlcb/OpenLCB_Technical_Introduction issues.

Documentation Copyright © 2025 John Socha‑Leialoha. See the repository for license details: LICENSE_DOCS.md and LICENSE.

Introduction

Purpose

The purpose of this book is to help you get up to speed on OpenLCB so you can start creating LCC products. There are a lot of concepts and we’ll go into just enough depth to get you going, without bogging you down with all the details and possibilites. The standards and the technical notes have a lot more details.

Focus of this book

We’re going to focus on how to create a device (node) that can be added to an OpenLCB network. To make learning easier, we’ll start with WiFi/TCP transport, which allows you to see network traffic easily and test without special hardware. Later chapters will cover adding CAN bus hardware for traditional LCC installations.

Assumptions

We’re assuming you’ve used LCC products, and therefore already understand concepts like the producer-consumer model. We also assume you’ve written code for microcontrollers and have worked with I/O pins.

About Names

Let’s start with what to some might be confusing—the names LCC vs OpenLCB. LCC is a brand name owned by the NMRA. It covers a set of standards that have been adopted by the NMRA for Layout Command Control (hence the LCC).

OpenLCB is the name of the working group that created the standards approved by the NMRA and continues to create new standards. This is a group of dedicated volunteers who are working to fulfill the dream.

You’ll notice that the standards use the name OpenLCB everywhere except for the header at the top of the document. In this book, as in the standards, we’ll use the term OpenLCB everywhere except this page.

Node

A Node is the basic building block of an OpenLCB network. It’s a device that can send and receive OpenLCB messages. We’ll describe these messages in more detail in a later chapter.

Each node has a unique 6-byte ID that must be assigned by the manufacturer. We’ll describe in a later chapter how you can get your own set of IDs to use in your products (or DIY boards).

Transport Layers

OpenLCB is designed to work over different physical transport layers. The two main options are:

CAN (Controller Area Network): The traditional transport for OpenLCB, used in most commercial LCC products. CAN uses a two-wire bus with excellent noise immunity and built-in arbitration, making it ideal for model railroad environments. It requires CAN transceiver hardware and proper bus termination.

WiFi/TCP: An alternative transport that uses standard WiFi networking and TCP/IP. This is particularly useful for development and learning because:

  • No special hardware required beyond WiFi-capable microcontrollers
  • Easy to monitor traffic using standard network tools
  • Can connect to JMRI and other tools over your home network
  • Simplifies initial prototyping and testing

In this book, we’ll start with WiFi/TCP transport because it’s more accessible for learning. Once you understand the core concepts, the principles transfer directly to CAN-based implementations. Future chapters will cover adding CAN hardware.

Both transports use the same OpenLCB protocols and message formats—only the physical layer changes.

Network Architecture

An OpenLCB network is made up of participants with different roles. The two primary roles are:

Nodes — devices that produce and consume events. Each node has a unique identifier and announces itself during startup.

Hubs — services that route and forward messages between participants. A hub may be implemented differently depending on the transport (for example, as a network server for TCP-based transports or as a bridge to a CAN bus). Hubs are optional in some deployments; on shared physical buses messages are exchanged directly between participants.

A single device can implement both node and hub functions. Examples in later chapters show convenient development setups where one device acts in both roles, but the concepts below are transport-agnostic.

Here’s a conceptual architecture diagram:

graph TB
	subgraph DeviceA["Device A"]
		NodeA["Node A<br/>(produces/consumes events)"]
	end

	subgraph Hub["Hub (optional)"]
		Router["Message Router / Hub"]
	end

	Monitor["Monitor / Tool<br/>(optional)"]
	DeviceB["Device B<br/>(other node)"]
	CAN["CAN Bus (optional)"]

	DeviceA ---|messages| Router
	DeviceB ---|messages| Router
	Monitor ---|connects| Router
	CAN -.->|physical transport| Router

	style DeviceA fill:#e1f5ff
	style NodeA fill:#fff9c4
	style Hub fill:#f3e5f5
	style CAN fill:#eeeeee
	style DeviceB fill:#eeeeee

What this shows:

  • Node: Produces and consumes events and announces itself on the network during startup.
  • Hub: Forwards messages between connected participants and may accept connections from monitoring tools.
  • Monitor/Tool: Observes network traffic and can send test messages.
  • Future Expansion: Multiple nodes and transports (e.g., CAN bridges, network servers) can be integrated behind hubs.

Why this design works:

  • Separation of concerns: The messaging protocol is transport-agnostic; nodes, hubs, and tools speak the same message formats regardless of physical layer.
  • Scalable: Hubs enable multiple devices and tools to interconnect without changing node implementations.
  • Flexible: Hubs can be implemented differently per transport, allowing the same protocol to work in embedded, wired, and networked environments.

Platform and transport choices (for example, why this book uses ESP32 and WiFi/TCP for early examples) are discussed in Chapter 2 (Getting Started).

Introduction to CAN

Controller Area Network (CAN) is a standard that was initially created in 1981 by Bosch, and has since been used for factory automation and communication networks in cars, to name just a few uses. Today there are hundreds of billions of devices using CAN bus in daily use.

As a result, there are a number of relatively inexpensive ICs available that fully implement the CAN specifications out of the box. By using CAN controllers and drivers, you do not have to write highly time-sensitive code. Instead, you can focus on implementing support for the messages in the application layers, which is a “relatively” simple task compared with implementing a transport layer.

CAN Messages

CAN transmits messages, called frames on the bus. Every other device (node) on the bus can (and must) listen to all of the traffic on the bus. One of the interesting and useful aspects of CAN is how it handles collisions, which is through arbitration.

CAN frames, in the form used by OpenLCB, consist of a 29-bit header followed by zero or more bytes of data. This is using the extended frame format, also know as CAN 2.0 B.

CAN Format

Arbitration

Arbitration is the process used to ensure that only one message is being transmitted at one time. If two nodes are trying to transmit different messages at the same time, one of those two will pass through the bus unharmed, while the other node will realize it lost the arbitration and attempt to re-transmit right after the first message is completed. This allows nearly 100% utilization of the bandwidth, because the node(s) that lost the arbitration will immediately stop, thereby not corrupting the frame that is being transmitted by the winner of the arbitration. This guarantees forward progress. In contrast, old style Ethernet (and LocoNet, btw.) uses a more complex scheme, where a collision destroys the frame, then nodes have to back off, wait a random period, then attempt to transmit again, thus wasting bandwidth.

The arbitration phase relies on drivers only pulling the bus low. If two nodes attempt to put different bit values on the bus at the same time, the 0 will always win. Here is a chart that shows how this works:

Node 1Node 2Bus Value
000
010
100
111

Dominant and recessive are the normal terms used because a ‘1’ bit (recessive) does NOT drive the bus. It allows the terminators to ‘drive’ the bus by pulling the two bus lines to a common voltage. (2.5V) On the other hand a ‘0’ bit (dominant) drives the bus to both high and low. (CANH goes to 5V and CANL goes to 0V) The bus drivers can always over power the terminators, and that is how the zero bit always dominates over the one bit. It also explains why at least some termination is required. The termination values are also chosen to suppress cable reflections, but that is actually only an issue on long lines. Also note that this explains the complexity of our RR-CirKits terminators. You can attach a logic analyzer to one of our terminators because it creates a logic level image of the bus (to drive the activity LED). You cannot connect a logic analyzer directly to the CAN bus, because it does not contain a logic level signal on either line.

Normally two nodes will happily send identical data until one node or the other transmits a zero bit during the other node’s one bit. At that point the recessive node notices that it transmitted a one bit,but received a zero bit back from the bus. This tells the recessive node that it is in conflict and must immediately stop transmitting. No other nodes can observe that this happened. This requirement for an immediate (within a fraction of the bit time) response is what limits the CAN bus segment lengths.

– Dick Bronson, RR Cirkits

Arbitration uses the 29-bit header value as a priority value to gain access to the bus, where lower values have higher priority. This mechanism provides very high utilization of the bus’ bandwidth because one node will always win a collision and keep transmitting. In contrast, Ethernet uses a more complex scheme where nodes have to back off, wait a random period, then attempt to transmit again, thus wasting bandwidth.

For arbitration to work sucessfully, each message sent by a node needs to use a unique 29-bit ID. The details for this are handled by the OpenLCB specifications, and are different for different types of messages.

Node Startup Sequence

When an OpenLCB node powers up or resets, it goes through a defined startup sequence to join the network. Understanding this sequence is essential to building your own nodes.

Note for Library Users: If you’re using OpenMRNLite or other OpenLCB libraries, all of the mechanics described in this chapter are handled automatically for you. You don’t need to implement alias reservation, collision detection, or recovery—the library does it all in the background.

This chapter exists to help you understand how it works, which is valuable knowledge when debugging network issues or designing advanced features. However, if you just want to build a working node with async_blink_esp32, you can skip straight to Chapter 3 without missing anything essential. The library takes care of it.

What Happens During Startup

Every node follows this sequence:

  1. Check ID (CID): The node sends four CID frames containing its 6-byte unique Node ID, spread across the frames. This allows the node to check if anyone else is using its desired alias.

  2. Reserve ID (RID): If no other node objects, the node sends an RID frame to reserve its chosen 12-bit alias for the Node ID.

  3. Alias Map Definition (AMD): The node announces the mapping between its full 6-byte Node ID and the 12-bit alias it just reserved.

  4. Initialized (Init Complete): The node sends an “Initialized” message telling the network it’s now fully online and reachable.

sequenceDiagram
    participant Node as New Node
    participant Bus as OpenLCB Network
    participant Others as Other Nodes
    
    Note over Node: Power On / Reset
    
    Node->>Bus: CID Frame 1 (Node ID bytes 0-1)
    Node->>Bus: CID Frame 2 (Node ID bytes 2-3)
    Node->>Bus: CID Frame 3 (Node ID bytes 4-5)
    Node->>Bus: CID Frame 4 (checks alias)
    
    Note over Node,Others: Wait for conflicts (200ms)
    
    Node->>Bus: RID (Reserve alias)
    Node->>Bus: AMD (Map Node ID to alias)
    Node->>Bus: Initialized Complete
    
    Note over Node: Now reachable on network
    
    Node->>Bus: Producer Identified (events)
    Node->>Bus: Consumer Identified (events)

Why Use Aliases?

OpenLCB uses 6-byte Node IDs to ensure every device in the world has a unique identifier. However, CAN bus headers only have 29 bits available. To fit the sender information plus message type, OpenLCB uses temporary 12-bit aliases that represent the full Node ID during a session.

This alias negotiation happens every time a node starts up. The aliases are not permanent—they’re regenerated each time the node powers on.

Node Participation

Other nodes on the network listen during this startup sequence. If another node is already using the alias the new node wants, it will send a conflict message, forcing the new node to pick a different alias. This ensures all active nodes have unique aliases.

Multi-Node Network Behavior

The startup sequence isn’t just about a single node announcing itself—it’s a conversation with the entire network:

Other nodes participate by:

  • Listening to all CID frames to check for alias conflicts
  • Responding with conflict messages if their alias is being claimed
  • Recording the Node ID to alias mapping from AMD frames
  • Acknowledging new nodes with responses to queries

This cooperative behavior ensures:

  • No two nodes ever use the same alias simultaneously
  • Nodes can discover each other’s capabilities
  • Gateways and bridges can manage routing efficiently
  • Network monitoring tools (like JMRI) can track all active nodes

When your node starts up, it’s not alone—the entire network is watching and ready to help it join successfully.

What Happens When Things Go Wrong

The startup sequence above describes the happy path—when everything works perfectly on the first try. In practice, nodes must be prepared to handle conflicts and retries.

Alias Collision Detection

If another node on the network is already using the alias your node wants to reserve, that other node will respond to your CID frames with a Reserve ID (RID) frame. This signals a collision:

  • During CID phase: If you receive an RID while sending your CID frames, your chosen alias is already in use. Your node must:

    • Abandon the current alias
    • Generate a new tentative alias
    • Start the entire CID → wait → RID sequence over from the beginning
  • During the 200ms wait: If another node transmits any non-CID frame using your tentative alias, you know there’s a collision and must restart.

Here’s what the collision and recovery process looks like:

sequenceDiagram
    participant Node as New Node
    participant Bus as OpenLCB Network
    participant Other as Node Already Online
    
    Note over Node: Generate Alias ABC
    
    Node->>Bus: CID Frame 1 (with alias ABC)
    Other->>Bus: RID (Collision!)
    Node->>Node: Detect Collision
    
    Note over Node: Collision detected!<br/>Generate New Alias DEF
    
    Node->>Bus: CID Frame 1 (with alias DEF)
    Node->>Bus: CID Frame 2 (with alias DEF)
    Node->>Bus: CID Frame 3 (with alias DEF)
    Node->>Bus: CID Frame 4 (with alias DEF)
    
    Note over Node,Other: Wait for conflicts (200ms)
    Note over Other: No conflict, silent
    
    Node->>Bus: RID (Reserve alias DEF)
    Node->>Bus: AMD (Map Node ID to alias DEF)
    Node->>Bus: Initialized Complete
    
    Note over Node: Now reachable with alias DEF

Your node’s alias generation algorithm (described in section 6.3 of S-9.7.2.1) ensures that each collision produces a different alias candidate, so nodes won’t get stuck in a loop trying the same alias repeatedly.

AMD and Alias Validation

Once you’ve successfully reserved an alias and sent your AMD (Alias Map Definition) frame, the alias mapping is established. However, your node must remain vigilant:

  • Alias Mapping Enquiry (AME): Other nodes can query your alias at any time using an AME frame. Your node is expected to respond with another AMD frame confirming the mapping.

  • Duplicate Node ID Detection: If your node receives an AMD frame from another node claiming to have the same 6-byte Node ID as you, this indicates a serious problem—two nodes with identical IDs exist on the network. Your node should:

    • Signal this condition to the user (LED blink pattern, log message, etc.)
    • Optionally transition back to the Inhibited state
    • Restart the alias reservation process with error handling

Collision Recovery in Your Code

When implementing your node:

  1. Always expect CID collisions - Your initial alias choice might conflict; be prepared to generate alternatives
  2. Implement retry logic - After detecting a collision during the CID phase, generate a new alias and restart
  3. Validate on receipt - When receiving AMD frames from other nodes, check for duplicate Node IDs
  4. Handle AME queries - Always respond to AME frames with AMD frames to maintain alias mappings

Most of this is handled transparently by OpenMRNLite, but understanding these scenarios helps when debugging network startup issues.

Note: For implementation details on alias collision handling and retry algorithms, see the async_blink_esp32 example code in Chapter 3, which demonstrates how OpenMRNLite handles these scenarios automatically.

References

For detailed protocol specifications, see:

CAN Frame Transfer (Node Startup Sequence)

Message Network (Initialization Complete)

Note: Future chapters will dive deeper into how the alias generation algorithm works and how to handle collisions in your code.

Events and Run Mode

After a node completes its startup sequence, it enters “run mode” where it actively participates in the network by producing and consuming events.

What is Run Mode?

Once a node is initialized and online, it:

  • Produces events in response to physical inputs or internal state changes
  • Consumes events from other nodes to control outputs or change behavior
  • Responds to queries from other nodes about its capabilities and status
  • Maintains its network presence by keeping its alias active

This is the normal operating state where your node does useful work on the layout.

Event Flow Example

Here’s a simple example of how events flow between nodes in run mode:

sequenceDiagram
    participant Button as Button Node
    participant Network as OpenLCB Network
    participant LED as LED Node
    
    Note over Button: User presses button
    Button->>Network: Produce Event<br/>05.02.01.02.02.00.00.01
    Network->>LED: Event received
    Note over LED: LED turns ON
    
    Note over Button: User releases button
    Button->>Network: Produce Event<br/>05.02.01.02.02.00.00.00
    Network->>LED: Event received
    Note over LED: LED turns OFF

In this example:

  1. The button node monitors a physical button
  2. When pressed, it produces an event with ID 05.02.01.02.02.00.00.01
  3. The LED node is configured to consume this event
  4. When it sees this event on the network, it turns on its LED
  5. A different event ID (...00.00.00) controls the LED turning off

Producer/Consumer Model

OpenLCB uses a producer/consumer event model:

  • Producers send events when something happens (button press, sensor trigger, timer expiration)
  • Consumers listen for specific events and react to them (turn on LED, throw turnout, sound horn)
  • A single node can be both a producer and consumer of different (or even the same) events

This decoupling is powerful: producers don’t need to know who’s listening, and consumers don’t need to know where events come from. You can reconfigure your layout by just changing which nodes consume which events.

Event Identification Messages

Part of the startup process includes announcing what events a node produces and consumes. The async_blink example sends these messages after completing initialization:

Producer Identified Valid:   Event 05.02.01.02.02.00.00.01
Producer Identified Valid:   Event 05.02.01.02.02.00.00.00
Consumer Identified Valid:   Event 05.02.01.02.02.00.00.01
Consumer Identified Valid:   Event 05.02.01.02.02.00.00.00

This tells the network: “I can produce these two events, and I can also consume the same two events.”

The “Valid” state means the node is actively configured to use these events. “Invalid” would mean the event is known but not currently in use.

Let’s look at what the async_blink example does in run mode. It’s intentionally simple to demonstrate the concepts:

Every second, it alternates:

  1. Produce event 05.02.01.02.02.00.00.01 (the “1” event)
  2. Because it also consumes this event, its own LED turns ON
  3. Produce event 05.02.01.02.02.00.00.00 (the “0” event)
  4. Because it consumes this event too, its own LED turns OFF

This creates a blinking LED controlled entirely through OpenLCB events, demonstrating the producer/consumer model in a single node.

In a real layout, you’d typically have separate nodes for inputs (buttons, sensors) and outputs (LEDs, turnouts), but the event flow works exactly the same way.

For detailed event protocol specifications, see:

Note: Future chapters will show you how to create your own nodes with real button inputs and LED outputs on an ESP32 microcontroller.

Getting Started

In the previous chapter, you learned about OpenLCB concepts: nodes, transport layers, startup sequences, and the producer/consumer event model. Now it’s time to plan your first OpenLCB node.

This chapter walks you through the key decisions we’ll make for building your node and explains the why behind each choice so you understand the trade-offs and can adapt to your own needs. (Actual implementation begins in the next chapter.)

What We’re Building

The goal of this book is to help you create an OpenLCB node—a device that can produce and consume events on an LCC network. Your node will:

  • Sense inputs (buttons, switches, sensors) and produce OpenLCB events
  • Control outputs (LEDs, relays, motors) by consuming OpenLCB events
  • Connect to a network so other nodes can respond to your events
  • Be monitored and configured via standard tools like JMRI

We’ll start with a simple example and scale to more complex scenarios in later chapters. The key concept is the pattern: once you understand how to produce and consume a single event, scaling to many inputs/outputs and adding complex logic becomes straightforward.

Progression: TCP, Then CAN, Then Full OpenMRN

We use three key technologies across this book, introduced progressively:

Phase 1: TCP/WiFi (Quick Start)

  • Use WiFi/TCP transport with Arduino framework and OpenMRN-Lite library to get your first node running in minutes
  • Focus on OpenLCB fundamentals (events, producers, consumers, CDI) without hardware barriers
  • No CAN transceiver required—just an ESP32 and JMRI for monitoring
  • Once you understand the protocol, adding CAN is straightforward

Phase 2: CAN Transport (Main Focus)

  • Add a CAN transceiver (~$5) and switch from WiFi/TCP to CAN transport
  • Same Arduino framework and OpenMRN-Lite library as phase 1
  • Explore inputs and outputs with the real transport layer used in most LCC installations
  • Build multi-node systems that communicate over CAN
  • Stay here if your needs fit this model (most hobby layouts do)

Phase 3: Full OpenMRN + FreeRTOS (Advanced)

  • Only if you need multi-threading, virtual nodes, or traction protocol
  • Migrate from Arduino to ESP-IDF (Espressif’s full RTOS environment)
  • Use full OpenMRN library with FreeRTOS threading
  • Handle complex, multi-threaded scenarios (bridges, command stations, etc.)

Each phase builds on the previous one. The OpenLCB knowledge transfers directly—what changes is the platform and threading model.

Key Decisions

Building an OpenLCB node requires several key decisions, explained in the later sections of this chapter:

  1. Which programming framework? — Arduino for phases 1–2, FreeRTOS only if phase 3 is needed
  2. Which OpenLCB library? — OpenMRN-Lite (Arduino) for phases 1–2, full OpenMRN only for phase 3
  3. Which platform? — ESP32 (recommended), STM32, or other microcontroller
  4. Which IDE and build tools? — PlatformIO (recommended), Arduino IDE, or native ESP-IDF
  5. Which transport? — TCP/WiFi for phase 1 (quick learning), CAN for phase 2 (main focus), both for phase 3
  6. How do I monitor and verify? — JMRI monitoring tool, TCP hub architecture, CAN bus analysis

By the end of this chapter, you’ll understand the reasons behind each choice and why this progression makes sense for learning and building real LCC systems.

Detailed Topic Coverage

Prerequisites

Before reading further, make sure you have:

Knowledge:

  • Basic embedded programming (C/C++)
  • Microcontroller I/O concepts (GPIO, digital read/write)
  • Familiarity with breadboards and simple circuits
  • Producer/consumer model understanding (from using LCC products)

Hardware (needed for Chapter 4):

  • ESP32 development board (ESP32 DevKit v1 or similar)
  • USB cable for programming
  • Computer with WiFi
  • Optional: breadboard, jumper wires, push button, LED, resistor (for hardware integration in Chapter 5)

Software (installation covered in Chapter 4):

  • VS Code or similar editor
  • PlatformIO extension (or Arduino IDE / Maker Workshop)
  • JMRI (for monitoring OpenLCB traffic)

If you’re missing any hardware, most items can be purchased as a kit from electronics suppliers (Adafruit, SparkFun, Amazon, AliExpress) for under $20 USD total.

What’s Next

You now have the big picture: a generic goal (inputs → events → outputs) plus the concrete stack for early chapters. Later sections in this chapter dive into each decision.

Start with Arduino for Early Chapters (Migration Path). Each section builds on the previous one, culminating in a clear understanding of our toolchain and ready-to-implement design.

By the end of this chapter, you’ll be prepared to move forward with the example implementation.

Arduino for Phases 1–2 (Migration Path for Phase 3)

The first decision in building an OpenLCB node is: which platform and framework? For phases 1–2, we use Arduino because it’s the most accessible starting point for learning and offers excellent real-world functionality. Phase 3 introduces the migration path to full FreeRTOS-based solutions only if you need advanced features.

Why Arduino First?

Arduino provides a dramatically lower learning curve and barrier to entry compared to FreeRTOS or ESP-IDF:

  • Minimal setup time: You can have working code running in minutes without complex build systems or operating system knowledge
  • Large ecosystem: Thousands of tutorials, libraries, and community examples
  • Rapid iteration: Fast compile/upload cycle (with PlatformIO)
  • Breadboard-friendly: Ideal for learning with hobby microcontrollers
  • No RTOS complexity: Single-threaded execution; no threading, mutexes, or scheduler concepts needed

When learning OpenLCB, you want to focus on understanding the protocol and building real I/O functionality—not spending weeks learning RTOS concepts and debugging multi-threaded interactions. Arduino (with OpenMRN-Lite) lets you get working nodes immediately.

Arduino + OpenMRN-Lite: Phases 1–2

For phases 1–2 (TCP quick-start and CAN-based I/O), you use OpenMRN-Lite, the Arduino version of OpenMRN.

OpenMRN-Lite is production-quality and fully functional. It supports:

  • CDI configuration - Configure nodes without recompiling
  • CAN bus - Full CAN transport with proper arbitration
  • Event producers/consumers - The core OpenLCB pattern
  • Persistent storage - SPIFFS/SD card for configuration
  • Real-time monitoring - JMRI integration via TCP or CAN

This is not a compromise. Most hobby and DIY LCC installations use OpenMRN-Lite for years. You’re not learning a dead-end; you’re choosing the right tool for real-world I/O projects.

Do You Need Phase 3?

Most projects stay in phases 1–2. Phase 3 (FreeRTOS + Full OpenMRN) is only for advanced scenarios:

  • Command stations and bridges — Complex routing between multiple transports
  • Virtual nodes — Multiple logical nodes on one device
  • Traction protocol — Locomotive decoder features
  • Multi-threaded hubs — Complex systems requiring background threads and advanced concurrency

If you’re building sensors, I/O controllers, or simple nodes for a layout, phases 1–2 have everything you need. Most hobby and model railroad installations never require phase 3.

If You Need Phase 3 Later

If you eventually need phase 3 features, the foundation you built in phases 1–2 makes the path forward straightforward:

  • Your OpenLCB protocol knowledge transfers directly — message formats, event semantics, CDI structures all stay the same
  • You’ll switch from Arduino to ESP-IDF (Espressif’s full RTOS environment) and use full OpenMRN library instead of OpenMRN-Lite
  • You’ll add FreeRTOS-aware code (threading, message queues, etc.)
  • The core OpenLCB concepts you’ve mastered (producers/consumers, startup sequence, events) remain unchanged—you’re adding threading sophistication on top of a solid foundation

For Phases 1–2

We’ll use Arduino + OpenMRN-Lite. This combination gives you:

  • A working TCP node in minutes (phase 1)
  • A fully-featured CAN node for real layouts (phase 2)
  • Time to focus on OpenLCB concepts instead of RTOS complexity
  • A solid foundation for phase 3 if you ever need it

Let’s get started with simplicity, solid fundamentals, and the knowledge that you can expand whenever you need to.

OpenMRN-Lite Architecture & Capabilities

You’ve learned the concepts behind OpenLCB. Now, before we write code, let’s understand the software library we’ll be using: OpenMRN-Lite. This chapter clarifies what it is, why it’s the right choice for ESP32, and what it can (and can’t) do.

What is OpenMRN-Lite?

OpenMRN-Lite is the Arduino version of OpenMRN.

This might sound like it’s a cut-down or simplified version, but that would be misleading. Instead, think of it as a version optimized for single-threaded, resource-constrained environments like Arduino and ESP32.

There are two ways to run OpenMRN code:

VersionThreading ModelBest ForPlatform
Full OpenMRNMulti-threaded (FreeRTOS)Complex systems, command stations, bridgesLinux, macOS, Windows, native ESP-IDF
OpenMRN-LiteSingle-threaded executorLearning, sensors, simple controllersArduino IDE, PlatformIO (Arduino framework) on ESP32, STM32

The key insight: Arduino cannot run the multi-threaded version of OpenMRN. The Arduino runtime environment doesn’t provide the POSIX threading APIs that full OpenMRN requires. Therefore, OpenMRN-Lite is not a “lite” compromise—it’s the only OpenMRN version available for Arduino.

Why OpenMRN-Lite is the Right Choice for Learning OpenLCB

When building an OpenLCB node on ESP32 using Arduino, OpenMRN-Lite is the right tool for this learning path because of its simplicity and low barrier to entry.

The Alternative: You could use the full OpenMRN library with native ESP-IDF (Espressif’s real-time operating system for ESP32). This gives you access to advanced features and the full power of FreeRTOS threading. But it also requires:

  • Understanding real-time operating systems, threading, and synchronization primitives
  • Managing POSIX APIs and FreeRTOS queues before you even write a single OpenLCB message
  • Debugging complex multi-threaded interactions
  • A completely different development environment and build system

For learning OpenLCB concepts, that’s like learning to drive by starting with a race car instead of a regular car.

Why OpenMRN-Lite Instead:

  • Familiar environment - Arduino’s setup()/loop() model is straightforward and widely understood
  • Focus on OpenLCB - You can understand nodes, events, and producers/consumers without threading complexity
  • Fast results - You’ll have working code sending real OpenLCB messages within hours
  • Proven examples - The IOBoard example demonstrates CDI configuration, events, and hardware I/O patterns you can build on
  • Natural progression - Once you master OpenLCB concepts, you can migrate to ESP-IDF + full OpenMRN if needed

This isn’t a permanent limitation—it’s a strategic choice that lets you learn faster and more effectively. Once you understand OpenLCB deeply, you’ll be better equipped to understand why those advanced features (virtual nodes, traction protocol, multi-threading) exist and when you might need them.

What OpenMRN-Lite DOES Support

OpenMRN-Lite has everything you need to build real, functional OpenLCB nodes:

Core Features

  • CDI (Configuration Description Information) - Define configuration options that JMRI can edit without recompilation
  • SNIP (Simple Node Information Protocol) - Share node name and description with the network
  • Event Producers & Consumers - The core OpenLCB pattern you learned in Chapter 1
  • Datagrams - Reliable message delivery for configuration and data exchange
  • ACDI (Abbreviated CDI) - Simpler configuration interface for basic nodes
  • CAN Transport (optional) - Add a CAN transceiver to use CAN bus instead of WiFi
  • TCP Hub (optional) - Connect to JMRI over WiFi using GridConnect protocol
  • Factory Reset Patterns - Reset to known states without recompilation
  • Persistent Configuration - Store settings in SPIFFS or SD card

The IOBoard example (the most complete OpenMRN-Lite example) demonstrates all of these features in action: CDI-based configuration, event handling, hardware I/O, and network integration. It’s exactly the pattern you’ll use when building your own nodes.

What OpenMRN-Lite Does NOT Support

There are some advanced features that require full OpenMRN with FreeRTOS threading:

What You Don’t Get

  • Virtual Nodes - Hosting multiple logical nodes on one microcontroller
  • Traction Protocol - Command station features (throttle control, trains)
  • Multi-Transport Bridging - Routing messages between CAN and TCP automatically
  • Multi-Threaded I/O - Background threads for independent subsystems
  • Hub Services - Virtual topology management and advanced networking
  • Extensive Memory Configurations - Very large configuration systems

These features aren’t missing from OpenMRN-Lite because the developers cut corners. They’re missing because they require the threading and memory management capabilities that FreeRTOS provides, which the Arduino framework doesn’t offer.

This is not a limitation for learning OpenLCB. In fact, OpenMRN-Lite’s simplicity makes it easier to understand how OpenLCB works without being buried in threading complexity.

When to Use OpenMRN-Lite vs Full OpenMRN

Use this decision matrix to understand which tool is right for your project:

Your NeedUseWhy
Learning LCC on ESP32OpenMRN-LiteSingle-threaded, Arduino-native, proven examples
Building a sensor node (button, LED, turnout)OpenMRN-LiteMinimal code, small footprint, stable
Fixed-function controller (no changes after deployment)OpenMRN-LiteNo runtime configuration needed; extremely reliable
Hosting multiple nodes on one ESP32❌ Not possible with ArduinoRequires FreeRTOS; switch to native ESP-IDF + full OpenMRN
Building a command station❌ Not possible with ArduinoRequires traction protocol + FreeRTOS; use Linux or dedicated hardware
Bridging CAN and TCP automatically❌ Not possible with ArduinoRequires multi-threading; use Linux

The key takeaway: If you’re using Arduino/PlatformIO on ESP32, OpenMRN-Lite is the only OpenMRN version available. There is no “upgrade path” to full OpenMRN while staying in the Arduino ecosystem. If you need full OpenMRN features, you switch toolchains entirely (to native ESP-IDF + FreeRTOS), which requires rewriting in a different style.

Configuration & Learning Implications

One important capability worth highlighting: OpenMRN-Lite fully supports CDI, the configuration system. This means you can:

  1. Define configuration options in your code (node name, GPIO pins, event IDs, etc.)
  2. Connect to JMRI and see those options in a graphical interface
  3. Change configuration without recompiling or uploading new firmware
  4. Persist changes to the ESP32’s filesystem (SPIFFS)

This is powerful for learning because:

  • You can experiment with event IDs without recompiling
  • You understand how real OpenLCB nodes work (configuration happens at runtime)
  • JMRI becomes a tool for monitoring and controlling your node

In this book’s v0.1, we’ll start with hardcoded configuration for simplicity. In later chapters (Chapter 5), we’ll enhance the example to use CDI configuration, showing you the pattern for building production nodes.

Looking Ahead

In Chapter 3, you’ll see how OpenMRN-Lite integrates into a real ESP32 project using Arduino and PlatformIO. The library handles all the protocol details—your job is just to define events, read inputs, and write outputs.

And if you ever do need features that OpenMRN-Lite doesn’t support, that’s okay. You’ll understand the OpenLCB concepts deeply enough to appreciate why those features exist and how they work in more complex systems. The goal of this book is to build that foundation.

Platform: ESP32 & SPIFFS

Now that we’ve committed to Arduino and OpenMRN-Lite, the next decision is: which microcontroller platform? We’ve chosen ESP32 for this book. Here’s why.

Why ESP32?

The ESP32 platform is one of the best-supported microcontrollers for OpenMRN-Lite:

Essential Features:

  • Excellent Arduino support - Arduino framework is mature and stable on ESP32
  • WiFi built-in - Perfect match for our WiFi/TCP transport choice (no separate shield needed)
  • CAN capable - Built-in CAN controller for adding CAN hardware in future chapters
  • Persistent storage (SPIFFS) - Critical for OpenMRN configuration management (more on this below)

Practical Advantages:

  • Affordable - Development boards are $5–15 USD
  • Powerful - Dual-core processor, plenty of memory for OpenMRNLite applications
  • GPIO-rich - Enough pins for inputs, outputs, and future expansion

SPIFFS: Why It Matters

OpenMRN-Lite requires a persistent filesystem to store configuration data. When your node starts, it reads a configuration file that contains:

  • Node identity (SNIP data: device name and description)
  • Configuration schema definition (CDI)
  • User settings (to be added in later chapters)

SPIFFS (SPI Flash File System) is ESP32’s built-in filesystem. It allows OpenMRN to:

  1. Store a configuration file on the device’s flash memory
  2. Read and update configuration without needing a separate EEPROM chip
  3. Persist settings across power cycles

This means:

  • No external hardware required (everything is on-chip)
  • You can change node name and description via JMRI without recompiling firmware
  • Future configuration parameters (WiFi settings, event mappings, etc.) can be edited live

Other platforms (Arduino Mega, STM32 Nucleo boards) either lack SPIFFS or require external EEPROM components, adding complexity. ESP32 has it built-in.

Other Platforms with OpenMRN-Lite Support

OpenMRN-Lite works on other Arduino-compatible boards:

  • STM32 family (Nucleo boards) via Arduino core - good option but requires external EEPROM for storage
  • Arduino Mega - more memory than Uno, but no WiFi; requires WiFi shield
  • Other ESP32 variants (ESP32-S3, ESP32-C3) - similar capabilities, also good choices

If you have a different board available, you can likely adapt the examples. Chapter 3 has more details on platform trade-offs. For now, ESP32 DevKit v1 is the best starting point: affordable, well-documented, and fully supported by the OpenMRN-Lite community.

Next: Choosing Your Development Environment

With platform and toolchain locked in (Arduino + OpenMRN-Lite + ESP32), the next step is choosing an IDE and build system. That’s covered in the next section.

Development Environments & Tooling

Now that you’ve decided on Arduino + OpenMRN-Lite + ESP32, you need tools to write, build, and upload code. There are three main options for Arduino development. This section compares them so you can choose what works best for you.

Three Options for Arduino Development

1. Arduino IDE

What it is: The official Arduino development environment. A simple editor with integrated build and upload tools.

Pros:

  • Official and most well-documented
  • Simplest setup for beginners
  • Latest Arduino library support (sometimes ahead of other tools)

Cons:

  • Limited editing features compared to professional IDEs
  • No advanced debugging tools
  • Library management can be tedious for complex projects
  • Slower builds for large projects
  • Limited integration with external tools (like GitHub Copilot)

Visual Studio Code is a powerful, modern code editor that supports Arduino development through extensions. Unlike the Arduino IDE, VS Code gives you professional editing features, integrations, and an extensive ecosystem of tools—all in one environment. This significantly improves your development experience.

Benefits of VS Code for Arduino Development:

  • Professional editor experience - syntax highlighting, code formatting, IntelliSense, and multi-file navigation
  • GitHub Copilot integration - AI-assisted code completion, documentation generation, and debugging suggestions
  • Extensible ecosystem - seamless access to Git, terminal, testing tools, debugging, and thousands of extensions
  • Faster development workflow - superior editing and navigation compared to Arduino IDE

There are two main extensions available for Arduino development in VS Code:

2A. Arduino Maker Workshop (VS Code Extension)

What it is: An extension that brings the Arduino CLI directly into VS Code, giving you the same capabilities as the official Arduino IDE.

Pros:

  • Full Arduino CLI capability - identical board and library support as the official Arduino IDE; no loss of functionality
  • Good library management - same library ecosystem as Arduino IDE

Cons:

  • Smaller community than PlatformIO
  • May have minor library update lag compared to official Arduino IDE (rarely an issue in practice)

2B. PlatformIO (VS Code Extension)

What it is: A professional-grade build system and IDE extension specifically designed for embedded development across multiple platforms.

Pros:

  • Fastest builds - significantly faster than Arduino IDE or Maker Workshop
  • Professional features - library version pinning, dependency resolution, build system profiling
  • Strong community - large ecosystem of libraries and examples
  • Non-Arduino platform support - extends beyond Arduino boards to ESP-IDF, FreeRTOS, STM32, and hundreds of other embedded platforms; supports phase 3 migration to full OpenMRN

Cons:

  • Latest Arduino library versions sometimes lag by 1–2 releases (minor issue in practice)
  • Slightly steeper learning curve than Arduino IDE
  • More configuration options (which is a pro for power users)

Personal Recommendation: PlatformIO + VS Code + GitHub Copilot

I personally use PlatformIO in VS Code with GitHub Copilot (paid subscription) enabled. Here’s why:

  1. Professional environment - PlatformIO’s build system is industrial-strength; you’re learning with real tools you’d use in production.
  2. GitHub Copilot - The entry-level paid subscription (~$10/month) is exceptional value. Copilot goes way beyond auto-completion; I use it to write and edit actual code. This dramatically lowers the barrier to entry and makes you far more productive:
    • Writing functions and complex logic from natural language descriptions
    • Refactoring and improving existing code
    • Generating unit tests and helper functions
    • Explaining unfamiliar code and OpenMRN APIs
  3. Fast iteration - PlatformIO’s build speed means less waiting between code changes and testing.
  4. Future flexibility - If you later migrate to full OpenMRN, PlatformIO supports it seamlessly.

The Copilot subscription is worth the investment if you’re doing this seriously. It’s not a necessity—you can learn OpenLCB without it—but it significantly improves the learning experience and accessibility for hobbyists.

Choosing Your Own Path

Each option is valid:

  • Lowest barrier to entry? → Arduino IDE
  • VS Code + Arduino CLI? → Arduino Maker Workshop
  • Professional, fastest, future-proof? → PlatformIO

This book includes detailed setup instructions for PlatformIO (Chapter 3). If you prefer a different option, the concepts apply equally; you’ll just follow that tool’s documentation for the build and upload steps.

Transports: WiFi/TCP and CAN

OpenLCB is designed to work over different physical transports. We use two main options across this book: WiFi/TCP (for rapid learning) and CAN (for real-world deployments).

Phase 1: WiFi/TCP Transport (Quick Start)

We begin with WiFi and TCP to get your first OpenLCB node running in minutes:

For Rapid Learning:

  • No special hardware required - ESP32 boards have WiFi built-in; no CAN transceivers needed
  • Easy monitoring - Standard network tools and JMRI can capture all traffic
  • Faster iteration - Wireless upload and debugging without physical bus connections
  • Focus on protocol - Understand events, producers, and consumers without hardware distractions

Key Insight: The OpenLCB message formats, node startup, and event handling work identically over WiFi/TCP and CAN. Everything you learn in phase 1 transfers directly to CAN-based implementations in phase 2. TCP is a deliberate stepping stone, not a dead-end.

Phase 2: CAN Bus Transport (Main Focus)

CAN (Controller Area Network) is the traditional transport for OpenLCB and is used in the vast majority of commercial LCC products. After mastering the protocol over TCP, you’ll add CAN hardware and use it as your primary transport.

Why CAN is the Real-World Standard:

  • Excellent noise immunity - designed for harsh environments (layout with motors, lights, switching supplies)
  • Built-in collision handling - arbitration is hardware-enforced, not software-managed
  • Two-wire bus - simple termination (120Ω at both ends)
  • Industry-proven - decades of use in automotive and industrial settings
  • Multi-device scalability - hundreds of nodes can share the same two-wire bus
  • Compatibility - interoperability with commercial LCC products and installations

What You’ll Add:

  • A CAN transceiver board (~$5)
  • Proper bus termination (two 120Ω resistors)
  • A two-wire connection to other nodes

When Phase 2 Starts: Chapters on inputs and outputs focus on CAN. You’ll explore multi-node systems, real-world layouts, and the patterns used in most model railroad installations.

Phase 3: Multi-Transport Integration (Advanced)

Once you’ve mastered CAN-based I/O, you may eventually need to:

  • Bridge TCP and CAN on the same device
  • Implement virtual nodes
  • Use the traction protocol for command stations

These advanced features require full OpenMRN with FreeRTOS threading, covered in later chapters.

The Progression

This is one of OpenLCB’s strengths: the protocol is transport-agnostic. You can:

  1. Start with WiFi/TCP on your workbench (phase 1—no special hardware)
  2. Learn OpenLCB fundamentals (events, producers, consumers, CDI)
  3. Add CAN hardware and switch to phase 2 (same code patterns; now using the real transport)
  4. Integrate both transports if needed (phase 3, only when necessary)

The key insight: learning with TCP doesn’t waste your time. It’s a deliberate stepping stone that lets you focus on protocol before worrying about hardware. When you move to CAN, the only change is the physical connection and a few hardware initialization lines—your OpenLCB knowledge applies perfectly.

Monitoring & Verification

Building an OpenLCB node is only half the battle. You also need visibility into what your node is doing. This section introduces the monitoring tools and how to verify your node is working correctly.

JMRI: The Essential Monitoring Tool

JMRI (Java Model Railroad Interface) is a free, open-source tool for model railroad control and monitoring. It’s essential for OpenLCB development:

Why JMRI?

  • Message decoder - Translates raw hex into readable OpenLCB messages
  • Network monitor - See all CID, RID, AMD, event messages in real-time
  • Testing tool - Send events to your node and verify responses
  • Layout integration - Connect your node to a larger LCC network
  • Configuration editor - Edit node settings (via CDI) without recompiling firmware

For OpenLCB development, JMRI is invaluable for seeing what’s happening on the network.

TCP/GridConnect Protocol

When your ESP32 node runs a TCP server (on port 12021 by default), JMRI connects as a TCP client. The communication uses GridConnect ASCII format, which is human-readable:

:X18AD4000N;
:X19B84000N;
:X1CED4000N;
:X1080C000N;

Each line is an OpenLCB message. The format is:

  • :X - GridConnect header
  • 18AD4000 - OpenLCB header and data (hex)
  • N - Indicates normal message (not error)
  • ; - Message terminator

What You’ll See: When your node starts up, JMRI shows:

  1. Four CID frames (checking alias availability)
  2. RID frame (reserving an alias)
  3. AMD frame (mapping Node ID to alias)
  4. Initialized message (node is online and ready)
  5. Producer/Consumer Identified messages (node capabilities)
  6. Event reports (button presses, LED changes, etc.)

This startup sequence takes a few milliseconds and happens automatically.

Running a Local TCP Hub

Your ESP32 node runs both a node (producing/consuming events) and a hub (routing messages) simultaneously. The hub is a simple TCP server:

  • Listens on port 12021 by default
  • Accepts connections from JMRI, other nodes, and monitoring tools
  • Forwards all OpenLCB messages between participants
  • Requires just a few lines of OpenMRN-Lite code to set up

This is a key design pattern: a single device can be both a node and a hub, which is perfect for development and small layouts.

Quick Verification Steps

When your node is running:

  1. Serial monitor - See startup messages and debug output
  2. JMRI connection - Connect JMRI to localhost:12021 (or your ESP32’s IP address)
  3. Message trace - Watch the four startup frames (CID/RID/AMD/Init)
  4. Event test - Produce and consume a test event; watch it appear in JMRI
  5. Node properties - View your node’s name and description (from SNIP data)

Chapter 4 includes detailed screenshots and step-by-step JMRI configuration instructions.

What’s Next

With monitoring tools in place, you’re ready to:

  1. Install PlatformIO (Chapter 3)
  2. Build and deploy the async_blink example
  3. Verify it with JMRI
  4. Start understanding the code

Let’s get hands-on in the next chapter.

Your First WiFi-Based OpenLCB Node

This chapter covers everything needed to build and deploy your first OpenLCB node on an ESP32 microcontroller using Arduino and PlatformIO, connected via WiFi.

Overview

The ESP32 is a powerful, affordable microcontroller with built-in WiFi connectivity, making it ideal for learning OpenLCB concepts. This chapter walks through building a working OpenLCB node that simultaneously runs the node protocol stack and hosts a TCP Hub—allowing JMRI to monitor and control your WiFi-connected node over a network.

Unlike “real” OpenLCB networks (which typically use CAN hardware transport), this WiFi-based approach lets you get a functioning node running quickly without special hardware, and it’s perfect for embedded systems with network connectivity.

As described in the “Network Architecture” section of Chapter 2, your ESP32 will act as both:

  • An OpenLCB node that produces and consumes events
  • A TCP Hub listening on port 12021 for JMRI connections

We’ll be using the OpenMRN-Lite library, which is the Arduino version of OpenMRN. If you’re curious about why OpenMRN-Lite specifically, or what it can (and can’t) do, see Chapter 2.2 (“OpenMRN-Lite Architecture & Capabilities”) for a deeper dive. For now, know that it’s the right tool for the job and it has everything we need to build real, functional OpenLCB nodes.

Board Selection

ESP32 Board Selection

Before installing software, you’ll want to order an ESP32 development board. This section helps you choose the right board for this tutorial.

The ESP32 family includes many board variants. For this tutorial, we recommend boards with sufficient GPIO pins, USB programming support, and built-in CAN capability for future chapters.

ESP32 DevKit V1 / ESP32-WROOM-32 (Xtensa architecture)

  • Most common and affordable ESP32 development board
  • 30+ GPIO pins available (plenty for expansion)
  • Built-in USB-to-serial converter (CP2102 or CH340)
  • 4MB flash memory (sufficient for OpenMRNLite applications)
  • Built-in CAN controller (TWAI) for future CAN chapters
  • Best OpenMRNLite compatibility - fully tested and supported
  • Available from multiple manufacturers (Espressif, DOIT, etc.)
  • Cost: $5-10 USD

This is the board we’ll use throughout the tutorial.

Alternative Boards

ESP32-DevKitC

  • Official Espressif development board
  • Similar pinout to DevKit V1
  • Excellent documentation and support
  • Slightly more expensive but guaranteed quality

ESP32-S3 (Xtensa architecture)

  • Newer variant with USB-OTG support
  • More memory and GPIO options
  • Built-in CAN controller
  • Good OpenMRNLite support

Boards to Avoid (for now)

ESP32-C3 (RISC-V architecture)

  • Different CPU architecture (RISC-V vs Xtensa)
  • OpenMRNLite has compatibility issues with ESP32-C3
  • Missing required ESP-IDF headers in Arduino framework
  • Wait for future OpenMRNLite updates before using

What You Need Now

To get started with the software-only version:

  • Just the ESP32 board with USB cable

For the hardware integration phase (optional, later):

  • Solderless breadboard
  • Tactile pushbutton
  • LED (any color)
  • 220Ω resistor
  • Jumper wires

Purchasing

ESP32 boards are available from:

  • Amazon / eBay: Search “ESP32 DevKit” (verify reviews)
  • AliExpress / Banggood: Direct from manufacturers (longer shipping)
  • Adafruit / SparkFun: Higher quality, better support, higher cost
  • DigiKey / Mouser: For bulk or commercial projects

Order your board now, then continue with the software setup while you wait for delivery.

Development Setup

PlatformIO Installation & Setup

PlatformIO is a professional embedded development platform that provides a unified build system, library management, and debugging tools. It integrates with VS Code to create a powerful development environment.

Installing VS Code

If you don’t already have Visual Studio Code installed:

  1. Download VS Code from code.visualstudio.com
  2. Run the installer for your operating system (Windows, macOS, or Linux)
  3. Follow the installation wizard with default options
  4. Launch VS Code after installation completes

Installing PlatformIO Extension

  1. Open VS Code
  2. Click the Extensions icon in the left sidebar (or press Ctrl+Shift+X / Cmd+Shift+X)
  3. Search for “PlatformIO IDE”
  4. Click Install on the “PlatformIO IDE” extension by PlatformIO
  5. Wait for the installation to complete (this may take several minutes as it downloads toolchains)
  6. Restart VS Code when prompted

After restarting, you should see a new PlatformIO icon (alien head) in the left sidebar.

Note: The ESP32 platform and toolchain will be installed automatically when you create your first project. PlatformIO handles all the toolchain downloads and configuration for you.

Project Structure

Now we’ll create the actual project we’ll be working with throughout this chapter. Instead of creating a throwaway test project, we’ll jump straight into building our OpenLCB node.

Create the Project

  1. Click the PlatformIO icon in the left sidebar
  2. Select New Project from Quick Access
  3. Enter project name: async_blink_esp32
  4. For Board, search and select DOIT ESP32 DEVKIT V1 (or esp32doit-devkit-v1)
  5. Framework should automatically select Arduino
  6. Click Finish

PlatformIO will:

  • Create the project structure
  • Download the ESP32 platform and toolchain (first time only, may take several minutes)
  • Set up the Arduino framework
  • Create a basic src/main.cpp file

Understanding the Project Structure

After creation, you’ll see this structure:

async_blink_esp32/
├── platformio.ini     # Project configuration
├── src/
│   └── main.cpp       # Your application code (we'll replace this)
├── lib/               # Project-specific libraries
├── include/           # Header files
└── test/              # Unit tests (optional)

The platformio.ini file should look like this:

[env:esp32doit-devkit-v1]
platform = espressif32@6.12.0
board = esp32doit-devkit-v1
framework = arduino
monitor_speed = 115200

Adding OpenMRNLite to Your Project

OpenMRNLite is the lightweight version of OpenMRN designed for Arduino-compatible platforms. It provides all the core LCC/OpenLCB functionality without the full complexity of the OpenMRN framework.

Installation via platformio.ini

Open the platformio.ini file in your async_blink_esp32 project and add OpenMRNLite to the library dependencies:

[env:esp32doit-devkit-v1]
platform = espressif32@6.12.0
board = esp32doit-devkit-v1
framework = arduino
lib_deps = openmrn/OpenMRNLite@2.0.0
monitor_speed = 115200

Save the file. That’s it! PlatformIO will automatically download OpenMRNLite from the registry when you build the project.

About OpenMRNLite version 2.0.0: We’re using v2.0.0 rather than newer versions because:

  • Version 2.0.0 is fully compatible with PlatformIO’s current ESP32 platform
  • Later versions (2.2.x+) require newer ESP-IDF features not yet available in PlatformIO
  • All core OpenLCB functionality is present in v2.0.0

About monitor_speed: This setting ensures the serial monitor uses 115200 baud, matching the Serial.begin(115200) call in our code. Without this, you’ll see garbled output instead of readable text.

About platform versions and Arduino-ESP32

Arduino-ESP32 has two major versions: 2.x and 3.x. The Arduino IDE now supports 3.x, but PlatformIO still uses 2.x. The platform = espressif32@6.12.0 pins us to PlatformIO’s current 2.x series.

Why not upgrade to Arduino-ESP32 3.x?

  • Arduino-ESP32 3.x is still evolving with breaking changes to the build system, partition handling, WiFi stack, and more.
  • PlatformIO prioritizes stability over bleeding-edge features. They thoroughly test each framework version across hundreds of boards, toolchains, and debuggers before releasing.
  • Smaller maintenance burden: By using a tested, stable version, we reduce variables when troubleshooting issues.

OpenMRNLite v2.0.0 is designed for the Arduino-ESP32 2.x that PlatformIO provides via espressif32@6.12.0. You’re trading bleeding-edge features for rock-solid reliability, which is perfect for learning OpenLCB fundamentals.

Note: The code in this chapter should also work with Arduino-ESP32 3.x using either the Arduino IDE or Arduino Maker Workshop, but we haven’t tested those configurations.

Verification

Let’s verify everything is working by building the project:

  1. In VS Code, click the PlatformIO icon in the left sidebar

  2. Under PROJECT TASKSesp32doit-devkit-v1, click Build

  3. PlatformIO will:

    • Download OpenMRNLite (first time only)
    • Compile the default main.cpp
    • Display build output
  4. Look for SUCCESS at the end of the output

Note: The first build will take longer as PlatformIO downloads the library and compiles it. Subsequent builds are much faster.

If the build succeeds, OpenMRNLite is installed correctly and you’re ready to write code!

Code Configuration

Code Configuration

Now we’ll create the actual OpenLCB node that produces alternating events. This demonstrates the core OpenLCB protocol behavior you learned in Chapter 1 (node initialization and event production) without requiring physical hardware.

Creating the Configuration Header

OpenMRNLite requires a configuration structure (CDI - Configuration Description Information) even for simple nodes. We’ll create a minimal config.h file.

Create the file include/config.h with this content:

#ifndef _ASYNC_BLINK_CONFIG_H_
#define _ASYNC_BLINK_CONFIG_H_

#include "openlcb/ConfigRepresentation.hxx"
#include "openlcb/MemoryConfig.hxx"

namespace openlcb {

/// SNIP Static Data - Manufacturer information (read-only, compiled into firmware)
extern const SimpleNodeStaticValues SNIP_STATIC_DATA = {
    4,               // Version
    "OpenMRN",       // Manufacturer
    "async_blink",   // Model
    "ESP32",         // Hardware version
    "1.00"           // Software version
};

/// SNIP Dynamic Data - User-editable node name and description
/// These are stored in the config file and can be read/written via JMRI
static const char SNIP_NODE_NAME[] = "async_blink";
static const char SNIP_NODE_DESC[] = "ESP32 Blink demo";

/// Version number for the configuration structure
static constexpr uint16_t CANONICAL_VERSION = 0x0001;

/// Minimal configuration segment with just internal config
CDI_GROUP(AsyncBlinkSegment, Segment(MemoryConfigDefs::SPACE_CONFIG), Offset(128));
CDI_GROUP_ENTRY(internal_config, InternalConfigData);
CDI_GROUP_END();

/// The main CDI structure
CDI_GROUP(ConfigDef, MainCdi());
CDI_GROUP_ENTRY(ident, Identification);
CDI_GROUP_ENTRY(acdi, Acdi);
CDI_GROUP_ENTRY(userinfo, UserInfoSegment, Name("User Info"));
CDI_GROUP_ENTRY(seg, AsyncBlinkSegment, Name("Settings"));
CDI_GROUP_END();

} // namespace openlcb

#endif // _ASYNC_BLINK_CONFIG_H_

What this does: Defines the CDI (Configuration Description Information) structure that OpenMRNLite uses to expose node configuration to JMRI. The configuration includes:

  • SNIP Static Data: Read-only manufacturer, model, and version information (compiled into firmware)
  • SNIP Dynamic Data: User-editable node name and description stored in the config file (visible in JMRI node properties)
  • Acdi and UserInfo: Standard OpenLCB configuration segments
  • AsyncBlinkSegment: Internal configuration area for this node (currently minimal, but available for future expansion)

Configuration Storage: Configuration is saved to SPIFFS on first boot (via factory_reset() shown in the next section) and persists across restarts. In v0.1, the initial configuration is hardcoded in the constants above. See the next chapter “Configuration & Persistence” (Chapter 4) to understand how configuration storage works, how to edit settings via JMRI, and how apply_configuration() fits into the lifecycle.

Code Implementation

Code Implementation

The Complete Main Code

Now replace the contents of src/main.cpp with the following code:

/** \copyright
 * Copyright (c) 2024, OpenLCB Technical Introduction
 * All rights reserved.
 *
 * Example code for educational purposes demonstrating OpenLCB node startup
 * and event handling on ESP32 using WiFi/TCP transport.
 *
 * \file main.cpp
 *
 * Simple async_blink example for ESP32 with WiFi - produces two alternating
 * events every second, demonstrating OpenLCB node initialization and event
 * production without requiring physical GPIO hardware.
 */

#include <Arduino.h>
#include <WiFi.h>
#include <SPIFFS.h>
#include <OpenMRNLite.h>
#include "utils/GcTcpHub.hxx"

#include "config.h"

// WiFi credentials - CHANGE THESE to match your network
const char* ssid = "YourWiFiSSID";
const char* password = "YourWiFiPassword";

// OpenLCB Node ID - must be unique on your network
// This ID is in the reserved range for experimental use
static constexpr uint64_t NODE_ID = 0x050201020200ULL;

// Event IDs that will be alternated
// These match the desktop async_blink example
static const uint64_t EVENT_ID_0 = 0x0502010202000000ULL;
static const uint64_t EVENT_ID_1 = 0x0502010202000001ULL;

// Create the OpenMRN stack object
OpenMRN openmrn(NODE_ID);

// TCP Hub for JMRI connectivity
GcTcpHub* tcp_hub = nullptr;

// ConfigDef comes from config.h and defines the configuration layout
static constexpr openlcb::ConfigDef cfg(0);

// OpenLCB configuration - required by OpenMRNLite
namespace openlcb {
  // Name of CDI.xml to generate dynamically
  const char CDI_FILENAME[] = "/spiffs/cdi.xml";
  
  // This will stop openlcb from exporting the CDI memory space upon start
  const char CDI_DATA[] = "";
  
  // Path to the config file and its size
  const char* const CONFIG_FILENAME = "/spiffs/openlcb_config";
  const size_t CONFIG_FILE_SIZE = cfg.seg().size() + cfg.seg().offset();
  
  // SNIP (Simple Node Information Protocol) dynamic data storage
  const char* const SNIP_DYNAMIC_FILENAME = CONFIG_FILENAME;
}

// State variable to track which event to send
bool event_state = false;

// Timing for event production (1 second = 1000 milliseconds)
unsigned long last_event_time = 0;
const unsigned long EVENT_INTERVAL = 1000;

/**
 * Configuration update listener for factory reset and config persistence.
 * 
 * factory_reset() is called automatically by OpenMRN on first boot (when the
 * config file doesn't exist yet). It initializes SNIP dynamic data (node name
 * and description) which is then saved to SPIFFS and persists across restarts.
 * 
 * apply_configuration() is called when the user modifies configuration through
 * JMRI (or other LCC tools). In v0.1, it returns UPDATED without doing anything.
 * In Chapter 5, we'll implement actual config persistence when this is called.
 */
class FactoryResetHelper : public DefaultConfigUpdateListener
{
public:
    UpdateAction apply_configuration(int fd, bool initial_load,
                                     BarrierNotifiable *done) OVERRIDE
    {
        AutoNotify n(done);
        // In v0.1, we don't handle runtime config changes yet.
        // Real nodes would persist changes here when the user modifies
        // configuration through JMRI. See Chapter 5 for implementation.
        return UPDATED;
    }

    void factory_reset(int fd) override
    {
        // Called on first boot to initialize the configuration file.
        // Write initial SNIP dynamic data (node name and description).
        // This data is then saved to SPIFFS and is displayed by JMRI
        // in the node properties dialog.
        cfg.userinfo().name().write(fd, openlcb::SNIP_NODE_NAME);
        cfg.userinfo().description().write(fd, openlcb::SNIP_NODE_DESC);
    }
} factory_reset_helper;

/**
 * Initialize Serial communication and print startup banner.
 */
void init_serial() {
  Serial.begin(115200);
  delay(500);  // Give serial time to initialize
  
  Serial.println("\n\n=== OpenLCB async_blink ESP32 Example ===");
  Serial.printf("Node ID: 0x%012llX\n", NODE_ID);
  Serial.printf("Event 0: 0x%016llX\n", EVENT_ID_0);
  Serial.printf("Event 1: 0x%016llX\n", EVENT_ID_1);
}

/**
 * Initialize SPIFFS filesystem for configuration storage.
 */
void init_filesystem() {
  Serial.println("\nInitializing SPIFFS...");
  if (!SPIFFS.begin(true)) {  // true = format if mount fails
    Serial.println("SPIFFS mount failed! Halting.");
    while (1) { delay(1000); }  // Stop here if filesystem fails
  }
  Serial.println("SPIFFS initialized successfully");
}

/**
 * Connect to WiFi network.
 */
void init_network() {
  Serial.printf("\nConnecting to WiFi SSID: %s\n", ssid);
  WiFi.begin(ssid, password);
  
  // Wait for WiFi connection
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  
  Serial.println("\nWiFi connected!");
  Serial.printf("IP Address: %s\n", WiFi.localIP().toString().c_str());
}

/**
 * Initialize OpenMRN stack and configuration.
 * This creates the config file and starts the stack.
 * FactoryResetHelper automatically initializes SNIP data on first boot.
 */
void init_openlcb_stack() {
  // Create the CDI.xml dynamically
  // CDI describes what configuration options are available
  Serial.println("\nCreating CDI configuration descriptor...");
  openmrn.create_config_descriptor_xml(cfg, openlcb::CDI_FILENAME);
  
  // Create the config file if it doesn't exist
  // OpenMRNLite requires this even for simple nodes
  Serial.println("Initializing OpenLCB configuration...");
  openmrn.stack()->create_config_file_if_needed(cfg.seg().internal_config(),
                                                  openlcb::CANONICAL_VERSION,
                                                  openlcb::CONFIG_FILE_SIZE);

  // Start the OpenMRN stack
  // This initiates the OpenLCB node initialization sequence:
  // 1. Check ID (CID) - verifies Node ID is unique
  // 2. Reserve ID (RID) - claims the Node ID
  // 3. Announce Membership (AMD) - announces node to network
  // 4. Initialization Complete - node enters normal operation
  Serial.println("\nStarting OpenLCB stack...");
  openmrn.begin();
  
  // Start the executor thread for background processing
  // REQUIRED for TCP Hub to accept connections
  Serial.println("Starting executor thread...");
  openmrn.start_executor_thread();
}

/**
 * Initialize TCP Hub for JMRI connectivity.
 */
void init_tcp_hub() {
  Serial.println("Starting TCP Hub on port 12021...");
  tcp_hub = new GcTcpHub(
    openmrn.stack()->can_hub(),  // Reference to the CAN hub
    12021                        // TCP port (standard for OpenLCB)
  );
  Serial.println("TCP Hub listening. JMRI can connect to this device on port 12021");
}

/**
 * Arduino setup() - runs once at startup
 * 
 * This function initializes all hardware and software subsystems:
 * 1. Serial communication
 * 2. SPIFFS filesystem
 * 3. WiFi network
 * 4. OpenMRN stack
 * 5. TCP Hub for JMRI connectivity
 */
void setup() {
  init_serial();
  init_filesystem();
  init_network();
  init_openlcb_stack();
  init_tcp_hub();
  
  Serial.println("OpenLCB node initialization complete!");
  Serial.println("Entering run mode - will alternate events every 1 second\n");
  
  // Record start time for event production
  last_event_time = millis();
}

/**
 * Arduino loop() - runs continuously
 * 
 * This function:
 * 1. Calls openmrn.loop() to process OpenLCB protocol messages
 * 2. Alternates between two events every second
 * 3. Prints event production to serial monitor
 */
void loop() {
  // CRITICAL: Must call openmrn.loop() frequently to process messages
  openmrn.loop();
  
  // Check if it's time to produce an event (every 1 second)
  unsigned long current_time = millis();
  if (current_time - last_event_time >= EVENT_INTERVAL) {
    // Alternate event state
    event_state = !event_state;
    
    // Send the event
    uint64_t event_to_send = event_state ? EVENT_ID_1 : EVENT_ID_0;
    openmrn.stack()->executor()->add(new CallbackExecutable([event_to_send]() {
      openmrn.stack()->send_event(event_to_send);
    }));
    
    // Print to serial monitor
    Serial.printf("Produced event: 0x%016llX (state: %d)\n", 
                  event_to_send, event_state ? 1 : 0);
    
    // Update timing
    last_event_time = current_time;
  }
}

Before You Build

Update WiFi credentials in the code! The build will succeed even with placeholder credentials, but the ESP32 won’t connect to WiFi when you upload it.

Important: Never check WiFi credentials (or any secrets) into a version control repository. In a real project, use environment variables or a separate configuration file excluded from git.

Better approach: OpenMRNLite provides Esp32WiFiManager for a more robust WiFi configuration system, but we’re using hardcoded credentials here for simplicity in this learning example.

Future note: WiFi is temporary for learning. When we add CAN transport in a later chapter, we’ll remove the WiFi code entirely. This approach helps you understand the differences between transport types.

Making These Settings Configurable

The code above uses hardcoded values for the event interval (1 second) and SNIP user data (node name and description). In Chapter 4, you’ll learn how to make these settings configurable via JMRI without recompiling the firmware.

See Chapter 4’s “Adding a Configurable Interval Setting” section to extend this example with runtime-editable parameters, and “Understanding Configuration Updates and Versioning” to understand the mechanisms that make configuration reliable.

Code Walkthrough

Code Walkthrough

This code is organized into:

  1. Configuration (config.h): Node identity and CDI structure
  2. Initialization (setup function with helpers): WiFi, SPIFFS, OpenLCB stack, TCP Hub
  3. Event production (loop function): Alternate between two events every second

The code includes detailed comments explaining each section. We’ll walk through the key concepts below.

1. Includes and WiFi Configuration

#include <Arduino.h>
#include <WiFi.h>
#include <SPIFFS.h>
#include <OpenMRNLite.h>

#include "config.h"

const char* ssid = "YourWiFiSSID";
const char* password = "YourWiFiPassword";

Required includes:

  • Arduino.h: Core Arduino framework
  • WiFi.h: ESP32 WiFi library for network connectivity
  • SPIFFS.h: ESP32 filesystem library for configuration storage
  • OpenMRNLite.h: OpenLCB protocol stack
  • config.h: Our configuration header with CDI definitions

Action Required: Replace ssid and password with your actual WiFi network credentials.

  • ESP32 only supports 2.4GHz WiFi networks (not 5GHz)
  • SSID is case-sensitive
  • This is hardcoded for simplicity - production code would use configuration storage

2. Node and Event IDs

static constexpr uint64_t NODE_ID = 0x050201020200ULL;
static const uint64_t EVENT_ID_0 = 0x0502010202000000ULL;
static const uint64_t EVENT_ID_1 = 0x0502010202000001ULL;

Node ID: Every OpenLCB node must have a globally unique 48-bit identifier. This ID (0x050201020200) is in the experimental range - safe for learning but not for production deployment.

Event IDs: These 64-bit identifiers represent the two events our node will produce. Notice they differ only in the last byte (00 vs 01), making them easy to track. These match the desktop async_blink OpenMRN example for consistency.

3. Configuration and OpenMRN Stack

OpenMRN openmrn(NODE_ID);
static constexpr openlcb::ConfigDef cfg(0);

namespace openlcb {
  const char CDI_FILENAME[] = "/spiffs/cdi.xml";
  const char CDI_DATA[] = "";
  const char* const CONFIG_FILENAME = "/spiffs/openlcb_config";
  const size_t CONFIG_FILE_SIZE = cfg.seg().size() + cfg.seg().offset();
  const char* const SNIP_DYNAMIC_FILENAME = CONFIG_FILENAME;
}

OpenMRN stack: Creates the entire OpenLCB protocol stack (message routing, node initialization, event handling, network transport).

ConfigDef: Instantiates the CDI configuration structure from config.h (already described above). The (0) parameter is the offset in memory.

OpenLCB namespace constants:

  • CDI_FILENAME: Path where the dynamic CDI.xml file will be written (used by JMRI for configuration discovery)
  • CDI_DATA: Empty string tells OpenMRN to generate CDI dynamically instead of using a static resource
  • CONFIG_FILENAME: Path to the config file in SPIFFS filesystem (note /spiffs/ prefix)
  • CONFIG_FILE_SIZE: Calculated as the size of all configuration segments. This ensures the file is large enough for all data (SNIP + internal config + UserInfo)
  • SNIP_DYNAMIC_FILENAME: Store SNIP data in the same file as config

4. FactoryResetHelper and Configuration Initialization

class FactoryResetHelper : public DefaultConfigUpdateListener
{
public:
    UpdateAction apply_configuration(int fd, bool initial_load,
                                     BarrierNotifiable *done) OVERRIDE
    {
        AutoNotify n(done);
        return UPDATED;
    }

    void factory_reset(int fd) override
    {
        cfg.userinfo().name().write(fd, openlcb::SNIP_NODE_NAME);
        cfg.userinfo().description().write(fd, openlcb::SNIP_NODE_DESC);
    }
} factory_reset_helper;

This class handles configuration lifecycle events:

factory_reset(int fd): Called by OpenMRN automatically on first boot when the config file is created. It initializes the SNIP dynamic data using OpenMRN’s built-in CDI framework:

  • cfg.userinfo().name().write(): Writes the node name to the correct offset in the config file
  • cfg.userinfo().description().write(): Writes the node description to the correct offset

This approach is better than manual file I/O because:

  • OpenMRN handles all byte offsets and layout automatically
  • Uses the same CDI structure (ConfigDef from config.h) consistently
  • Less prone to errors (no manual fseek, fwrite calls)
  • Foundation ready for apply_configuration() in future chapters when users modify config via JMRI

apply_configuration(): Currently returns UPDATED without doing anything. In v0.1, the config is read-only (no runtime changes). Chapter 4 explains what this callback does and why it exists. Later sections of Chapter 4 will implement it to handle real configuration changes.

SNIP data: The node name and description are displayed by JMRI in the node properties dialog, helping identify which ESP32 is which on your network.

5. WiFi Connection

void init_network() {
  Serial.printf("\nConnecting to WiFi SSID: %s\n", ssid);
  WiFi.begin(ssid, password);
  
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  
  Serial.println("\nWiFi connected!");
  Serial.printf("IP Address: %s\n", WiFi.localIP().toString().c_str());
}

This helper function establishes the WiFi connection before starting OpenLCB. The ESP32 won’t be able to communicate on the OpenLCB network until WiFi is connected, so we wait here.

The dots printed to serial provide visual feedback during connection.

6. Initialization Helper Functions and Setup

void init_openlcb_stack() {
  Serial.println("\nCreating CDI configuration descriptor...");
  openmrn.create_config_descriptor_xml(cfg, openlcb::CDI_FILENAME);
  
  Serial.println("Initializing OpenLCB configuration...");
  openmrn.stack()->create_config_file_if_needed(cfg.seg().internal_config(),
                                                  openlcb::CANONICAL_VERSION,
                                                  openlcb::CONFIG_FILE_SIZE);

  Serial.println("\nStarting OpenLCB stack...");
  openmrn.begin();
  
  Serial.println("Starting executor thread...");
  openmrn.start_executor_thread();
}

This helper function performs several critical initialization steps:

  1. Create CDI.xml dynamically: openmrn.create_config_descriptor_xml() generates a CDI (Configuration Description Information) file that describes all available configuration options. JMRI uses this file to know what settings the node supports. The file is written to /spiffs/cdi.xml.

  2. Create config file if needed: create_config_file_if_needed() ensures the config file exists with proper structure. On first boot:

    • File is created with space for internal config, SNIP data, and UserInfo
    • FactoryResetHelper’s factory_reset() is called automatically
    • SNIP user data (name and description) is populated
  3. Start OpenMRN stack: openmrn.begin() initiates the entire OpenLCB protocol sequence (CID, RID, AMD) as described in Chapter 1.

  4. Start executor thread: Background thread for processing OpenLCB messages. This is required for TCP Hub to work.

7. TCP Hub for JMRI Connectivity

void init_tcp_hub() {
  Serial.println("Starting TCP Hub on port 12021...");
  tcp_hub = new GcTcpHub(
    openmrn.stack()->can_hub(),
    12021
  );
  Serial.println("TCP Hub listening. JMRI can connect to this device on port 12021");
}

This helper function creates a TCP server on port 12021 using the GridConnect protocol (the ASCII format that JMRI expects). It’s passed:

  • openmrn.stack()->can_hub(): Reference to the message router
  • 12021: The TCP port to listen on (standard for OpenLCB TCP hubs)

The TCP Hub allows JMRI and other TCP clients to connect and monitor your node’s events in real-time. Multiple JMRI instances can connect simultaneously; messages are routed between all connected clients and the local node.

8. Main Setup Function

void setup() {
  init_serial();
  init_filesystem();
  init_network();
  init_openlcb_stack();
  init_tcp_hub();
  
  Serial.println("OpenLCB node initialization complete!");
  Serial.println("Entering run mode - will alternate events every 1 second\n");
  
  last_event_time = millis();
}

The setup() function calls four helper functions in sequence:

  1. init_serial(): Initialize Serial, print startup banner with Node ID and Event IDs
  2. init_filesystem(): Initialize SPIFFS filesystem
  3. init_network(): Connect to WiFi (required before OpenLCB)
  4. init_openlcb_stack(): Create CDI file, config file, initialize SNIP data via FactoryResetHelper, start OpenLCB protocol stack, start executor thread
  5. init_tcp_hub(): Start TCP server for JMRI connectivity

Why break it down? Each helper function focuses on a single responsibility, making the code easier to understand and modify. If you need to add new initialization steps or change how the node starts up, it’s clear where to make those changes.

9. Event Production Loop

void loop() {
  openmrn.loop();  // CRITICAL - processes all OpenLCB messages
  
  unsigned long current_time = millis();
  if (current_time - last_event_time >= EVENT_INTERVAL) {
    event_state = !event_state;
    uint64_t event_to_send = event_state ? EVENT_ID_1 : EVENT_ID_0;
    
    openmrn.stack()->executor()->add(new CallbackExecutable([event_to_send]() {
      openmrn.stack()->send_event(event_to_send);
    }));
    
    Serial.printf("Produced event: 0x%016llX (state: %d)\n", 
                  event_to_send, event_state ? 1 : 0);
    
    last_event_time = current_time;
  }
}

Critical Detail: openmrn.loop() must be called frequently (ideally every few milliseconds). This processes:

  • Incoming network messages
  • Outgoing message queues
  • Protocol state machines
  • Internal timers

Event Production: Every 1000ms (1 second), we:

  1. Toggle event_state (false → true → false → …)
  2. Select which event ID to send based on state
  3. Queue the event for transmission using the executor
  4. Print confirmation to serial monitor

Why use the executor? OpenLCB message handling runs in a separate execution context. The executor()->add() pattern ensures thread-safe event production.

What Happens Next

Now that you understand how the code works, the next section covers building this code and uploading it to your ESP32. You’ll see all of these initialization steps happen in the serial monitor, and we’ll verify everything is working correctly.

Build and Upload

Building and Uploading to ESP32

Now that you have the complete code, let’s build it, upload it to your ESP32, and verify it works through the serial monitor.

Building the Project

  1. Save the modified main.cpp file (Ctrl+S / Cmd+S)

  2. Open the PlatformIO sidebar:

    • Click the PlatformIO icon (alien head) in VS Code’s left sidebar
    • Or use the bottom toolbar’s checkmark icon (Build)
  3. Build the project:

    • In PROJECT TASKS → esp32doit-devkit-v1, click Build
    • Or click the checkmark (✓) icon in the bottom toolbar
    • Or press Ctrl+Alt+B / Cmd+Alt+B
  4. Watch the build output:

    Building in release mode
    Compiling .pio/build/esp32doit-devkit-v1/src/main.cpp.o
    Linking .pio/build/esp32doit-devkit-v1/firmware.elf
    Building .pio/build/esp32doit-devkit-v1/firmware.bin
    ========================= [SUCCESS] Took 5.23 seconds =========================
    

The first build takes longer because it compiles OpenMRNLite. Subsequent builds are much faster.

If the build fails, check:

  • WiFi credentials are properly quoted (strings)
  • All braces {} and parentheses () match
  • #include <OpenMRNLite.h> is present
  • lib_deps in platformio.ini includes OpenMRNLite

Connecting Your ESP32

  1. Connect the ESP32 to your computer via USB cable

    • Use a data cable, not a charge-only cable
    • The ESP32 should power on (onboard LED may light up)
  2. Identify the COM port (Windows) or device path (Mac/Linux):

    • PlatformIO usually auto-detects the port
    • Windows: COM3, COM4, etc.
    • Mac: /dev/cu.usbserial-* or /dev/cu.wchusbserial*
    • Linux: /dev/ttyUSB0 or /dev/ttyACM0
  3. If the port isn’t detected, you may need to install a USB driver:

    • CP2102: Download from Silicon Labs
    • CH340: Download from WCH
    • Most modern operating systems include these drivers

Uploading the Firmware and Opening the Monitor

  1. Upload the firmware and automatically open the serial monitor:

    • In PROJECT TASKS → esp32doit-devkit-v1, click Upload and Monitor
    • Or press Ctrl+Alt+U then Ctrl+Alt+S / Cmd+Alt+U then Cmd+Alt+S
  2. Watch the upload process:

    Configuring upload protocol...
    Looking for upload port...
    Auto-detected: COM3
    Uploading .pio/build/esp32doit-devkit-v1/firmware.bin
    esptool.py v4.5.1
    Connecting........__
    Chip is ESP32-D0WDQ6 (revision 1)
    Writing at 0x00010000... (100 %)
    Wrote 876544 bytes (543210 compressed) at 0x00010000 in 48.2 seconds
    Leaving...
    Hard resetting via RTS pin...
    ========================= [SUCCESS] Took 52.91 seconds =========================
    
  3. The ESP32 will automatically reboot and start running your code

If upload fails:

  • “Serial port not found”: Check USB cable connection, try different USB port
  • “Failed to connect”: Hold the BOOT button while clicking “Upload and Monitor”, release after “Connecting…” appears
  • Permission denied (Linux): Add your user to the dialout group: sudo usermod -a -G dialout $USER, then log out and back in

If you missed the startup sequence, press the RESET button on your ESP32 board to restart it and the serial monitor will display the output again.

Verifying the Output

On first run, you should see output like this:

=== OpenLCB async_blink ESP32 Example ===
Node ID: 0x050201020200
Event 0: 0x0502010202000000
Event 1: 0x0502010202000001

Initializing SPIFFS...
E (523) SPIFFS: mount failed, -10025
SPIFFS initialized successfully

Connecting to WiFi SSID: YourNetwork
.....
WiFi connected!
IP Address: 192.168.1.100

Creating CDI configuration descriptor...
[CDI] Checking /spiffs/cdi.xml...
[CDI] File /spiffs/cdi.xml does not exist
[CDI] Updating /spiffs/cdi.xml (len 987)
[CDI] Registering CDI with stack...
Initializing OpenLCB configuration...
Creating config file /spiffs/openlcb_config

Starting OpenLCB stack...
Starting executor thread...
Starting TCP Hub on port 12021...
TCP Hub listening. JMRI can connect to this device on port 12021

OpenLCB node initialization complete!
Entering run mode - will alternate events every 1 second

Produced event: 0x0502010202000000 (state: 0)
Produced event: 0x0502010202000001 (state: 1)
Produced event: 0x0502010202000000 (state: 0)
Produced event: 0x0502010202000001 (state: 1)
...

First Run Note: The initial startup (especially the “Creating CDI configuration” and “Creating config file” steps) takes 20-30 seconds due to SPIFFS filesystem formatting. The SPIFFS mount error is normal—the filesystem doesn’t exist yet, but it’s automatically created. Don’t interrupt the ESP32 during this time. Subsequent boots skip formatting and start much faster.

What to verify:

  • ✅ SPIFFS initializes successfully
  • ✅ WiFi connects successfully (shows your network name and IP address)
  • ✅ OpenLCB stack initializes
  • ✅ Events alternate between ...00 and ...01 every second
  • ✅ State toggles between 0 and 1

Understanding What You See

This output confirms your ESP32 is:

  1. Connected to WiFi: The IP address shows it’s on your network
  2. Running the OpenLCB stack: Node initialization completed successfully
  3. Producing events: The alternating event IDs prove the event production logic works
  4. Ready for network communication: The node is broadcasting these events on the OpenLCB network (you’ll verify this with JMRI next)

Congratulations! You have a working OpenLCB node. The events are being broadcast over WiFi/TCP, but you can’t see them on the network yet - that’s what JMRI will show you in the next section.

Common Issues

WiFi won’t connect (stuck on dots):

  • Verify WiFi credentials in code are correct
  • Check ESP32 is within range of your access point
  • Confirm your network is 2.4GHz (not 5GHz only)
  • Some corporate/school networks block device connections

No serial output at all:

  • Verify monitor_speed = 115200 is in your platformio.ini file
  • Try pressing RESET button on ESP32
  • Verify the serial monitor is connected to the correct port

Output is garbled/random characters:

  • Wrong baud rate - ensure monitor_speed = 115200 is in platformio.ini
  • If you already added it, stop and restart the serial monitor
  • Bad USB cable or connection

“Brownout detector triggered” errors:

  • Insufficient power from USB port
  • Try a different USB port or powered USB hub
  • This usually doesn’t prevent operation, just a warning

JMRI Monitoring

Testing with JMRI

Now that your ESP32 is producing events, let’s use JMRI (Java Model Railroad Interface) to monitor them on the network. JMRI acts as a “traffic monitor” that shows all OpenLCB messages, letting you verify your node is working correctly.

What is JMRI?

JMRI is an open-source application suite for model railroading that includes comprehensive OpenLCB/LCC support. In JMRI 5.x, LccPro is the authoritative tool for LCC configuration and monitoring. For our purposes, we’ll use JMRI to:

  • Monitor all OpenLCB messages on the network via LCC Monitor
  • See when nodes initialize (CID, RID, AMD messages)
  • Observe event production and consumption
  • View and configure node information via LccPro
  • Verify your ESP32 is communicating correctly

Think of it as a “network packet sniffer” for OpenLCB.

Installing JMRI

  1. Download JMRI:

    • Visit jmri.org
    • Click Download → Latest Production Release
    • Choose your operating system (Windows, macOS, or Linux)
    • Download and run the installer
  2. System Requirements:

    • JMRI 5.12 or later (LccPro requires JMRI 5.12+; earlier versions use the older DecoderPro LCC tools)
    • Java 11 or newer (usually bundled with JMRI installer)
    • Windows 7+, macOS 10.14+, or Linux with X11
    • ~500MB disk space
  3. Install JMRI:

    • Run the installer with default options
    • On macOS, you may need to allow the app in System Preferences → Security
    • On Linux, you may need to make the script executable: chmod +x JMRI-installer.sh

Configuring the LCC Connection

JMRI needs to know how to connect to your ESP32’s TCP Hub on port 12021. Let’s configure that connection.

  1. Launch LccPro:
    • In JMRI, go to ESP32 LCC → LccPro
    • If this is the first time you’re running LccPro and you have no other connections, the LccPro Wizard appears automatically (see first image below)
    • If you already have at least one connection from DecoderPro or PanelPro, continue to step 2

LccPro Wizard on first launch (no existing connections)

  1. Configure the connection settings:

    If using the Wizard (first-time launch):

    • Enter the connection details in the Wizard form (see steps 3 below for field definitions)
    • Click Next to proceed

    If you have existing connections:

    • Click Edit → Preferences in the LccPro window
    • Click the Connections tab (in the left sidebar)
    • Click the + button (bottom left) to add a new connection

JMRI Preferences dialog with Connections tab

  1. Enter the connection settings:

    • System manufacturer: Select LCC (this is the OpenLCB standard system name in JMRI)
    • System connection: Select CAN via GridConnect Network Interface
    • Connection name: Enter ESP32 LCC (or any descriptive name)
    • IP Address/Host Name: Enter the IP address displayed in your PlatformIO serial monitor window (look for the line “IP Address: 192.168.x.x” from your ESP32’s startup output)
    • TCP/UDP Port: Enter 12021
    • Connection Protocol: Select OpenLCB
  2. Save settings (required for both Wizard and Preferences):

    • Click Edit → Preferences in the LccPro window (if not already there)
    • Click Save at the bottom of the Preferences window
    • JMRI will prompt to restart - click Restart Now

Monitoring LCC Traffic

After JMRI restarts, let’s open the message monitor to see your ESP32’s events.

  1. Open the LCC Monitor:

    • Go to ESP32 LCC → Monitor Traffic
    • A new window opens showing a live feed of LCC messages
  2. Observe the event production:

    • You should see alternating event reports appearing every second
    • Each event corresponds to the events your ESP32 is producing

LCC Traffic Monitor showing alternating events

Viewing Node Properties (SNIP Information)

You can verify that JMRI recognizes your ESP32 as a node on the network and view its SNIP (Simple Node Information Protocol) details using LccPro:

  1. Open LccPro:

    • In JMRI, go to ESP32 LCC → LccPro
    • This opens the LCC configuration tool and displays the node list
  2. Find your ESP32 node:

    • You should see a node with the ID matching your code (050201020200)
    • The node list displays the SNIP (Simple Node Information Protocol) data:
      • Name: async_blink (your node name from config.h)
      • ID: 050201020200 (your node’s unique OpenLCB identifier)
      • Manufacturer: OpenMRN
      • Model: async_blink
      • Software: 1.00
      • Description: The description from SNIP_NODE_DESC
    • This confirms the node initialized successfully with correct identity information

LccPro node list showing the async_blink node

Note: The Configure button in LccPro is for editing the node’s configuration. We’ll explore that in Chapter 5.

Understanding SNIP vs ACDI (Important!)

Now is a good time to understand the distinction between two types of node information in OpenLCB:

SNIP (Simple Node Information Protocol):

  • Identifies what the device IS (manufacturer, model, hardware/software versions)
  • Hardcoded in firmware (in config.h as SNIP_STATIC_DATA)
  • Read-only - cannot be changed without recompiling
  • Displayed in LccPro → Identification tab when you view node properties
  • Examples: OpenMRN (manufacturer), async_blink (model)

ACDI (Abbreviated Configuration Description Information):

  • Stores layout identity - how YOU refer to the node in your model railroad
  • Persistent in SPIFFS (can be modified via JMRI without recompiling)
  • User-editable through LccPro
  • Includes fields like “User Name” (what you call the node) and “Description”
  • Examples: Main Station Blinker, Yard Controller #3

In this v0.1 example, both are initialized from config.h constants. In Chapter 5, you’ll learn to make ACDI values editable through JMRI using the configuration interface.

Optional: View Events as Sensors

Want to see your ESP32’s events visualized as sensor states? You can create sensors in PanelPro that correspond to your event IDs.

Important: For this section, you need to have PanelPro open. If you only have JMRI running:

  • Select File → Open PanelPro to launch it

Once PanelPro is open:

  1. Open the Sensor Table:

    • Go to Tools → Tables → Sensors
    • The sensor table displays any configured LCC sensors
  2. Add your first sensor:

    • Click Add to create a new sensor
    • In the dialog:
      • System Name: Enter the hardware address for your first event ID
        • For EVENT_ID_0 (0x0502010202000000), type: 0502010202000000
        • JMRI will automatically add the MS prefix when you click Create
      • User Name: Enter ESP32 Event 0 (or any descriptive name)
    • Click Create
  3. Repeat for the second event:

    • Click Add again
    • System Name: Type 0502010202000001
    • User Name: ESP32 Event 1
    • Click Create
  4. Watch the sensors:

    • Return to the sensor table
    • Verify the system names are correct:
      • Event 0 should show: MS0502010202000000
      • Event 1 should show: MS0502010202000001
    • As your ESP32 produces alternating events, observe the sensor behavior:
      • Expected behavior: The sensors will flash ACTIVE for a brief moment, then return to INACTIVE
      • Why the brief flash?: JMRI uses an event timeout mechanism. When an event is received, the sensor becomes ACTIVE. If the same event isn’t re-sent within the timeout window, the sensor automatically reverts to INACTIVE (a safety feature to prevent stale states if a node disappears from the network)
      • Pattern: Since your ESP32 alternates between EVENT_ID_0 and EVENT_ID_1 every second, you should see:
        • Second 1: Sensor 0 flashes ACTIVE, Sensor 1 stays INACTIVE
        • Second 2: Sensor 1 flashes ACTIVE, Sensor 0 stays INACTIVE
        • Second 3: Sensor 0 flashes ACTIVE, and so on…

LCC Sensors showing alternating ACTIVE states

This demonstrates the bidirectional nature of LCC - your JMRI sensors are consuming events produced by your ESP32!

Configuration & JMRI: CDI Discovery

When JMRI first connects to your ESP32, it automatically discovers what configuration options are available by requesting the CDI (Configuration Description Information) file. This file (generated in init_openlcb_stack() as /spiffs/cdi.xml) describes:

  • What segments are available (Internal settings, User info, Device-specific parameters)
  • What fields can be edited in each segment
  • Data types (text, numbers, enums, etc.)
  • Constraints (min/max values, field lengths, etc.)

In v0.1, the CDI is generated automatically from the ConfigDef structure in config.h. Your node currently exposes:

  • SNIP Identification: Manufacturer, model, hardware/software versions (read-only)
  • ACDI User Info: Node name and description (editable, but changes aren’t saved yet)
  • Internal Configuration: Reserved space for future parameters

When you view node properties in LccPro → Configure, JMRI uses this CDI to render the appropriate dialog fields.

In Chapter 4 (next chapter), you’ll:

  • Understand how configuration is stored in SPIFFS
  • Learn to edit your node’s name through JMRI and see changes persist
  • Understand factory_reset() behavior and SNIP static vs. dynamic data
  • Explore how apply_configuration() fits into the persistence lifecycle
  • Add configurable parameters (like EVENT_INTERVAL) to the CDI
  • Implement apply_configuration() to save user changes while the node is running
  • Learn how to version your configuration schema for forward compatibility
  • Test dynamic configuration updates through LccPro

For now, understand that your node is already capable of being configured via JMRI - the CDI file ensures JMRI knows what options your node supports.

Troubleshooting JMRI Connection

JMRI shows “Connection failed” or “No route to host”:

  • Verify the IP address matches what the ESP32 serial monitor showed
  • Ensure your computer and ESP32 are on the same WiFi network
  • Check firewall settings - allow Java/JMRI to access the network
  • Try pinging the ESP32: ping 192.168.1.100 (use your ESP32’s IP)

No messages appear in the monitor:

  • Click Clear in the monitor to reset the display
  • Press RESET on the ESP32 to trigger initialization messages
  • Check the connection status at the bottom of the JMRI window (should show “Connected”)

Messages appear but events don’t match:

  • Verify the event IDs in your code match what you’re looking for in JMRI
  • Check that you didn’t modify the EVENT_ID constants

“Connection lost” after working initially:

  • ESP32 may have rebooted or lost WiFi connection
  • Check the serial monitor for errors or WiFi reconnection attempts
  • ESP32’s IP address may have changed if DHCP lease renewed

Troubleshooting General Issues

Library not found during build:

  • Verify the lib_deps line in platformio.ini has no typos
  • Run PlatformIO: Clean from the command palette (Ctrl+Shift+P)
  • Rebuild the project

ESP32 not detected:

  • Check USB cable (must be data cable, not charge-only)
  • Install CP2102 or CH340 USB driver for your operating system
  • Try a different USB port

WiFi connection fails:

  • Verify SSID and password in code
  • Check that ESP32 is within range of access point
  • ESP32 only supports 2.4GHz WiFi (not 5GHz)

JMRI not seeing events:

  • Verify JMRI is configured for TCP GridConnect connection
  • Check that ESP32 and JMRI are on the same network
  • Verify the TCP server address and port in ESP32 code

What’s Next

You’ve now built your first OpenLCB node with WiFi transport!

In the next chapter, we’ll cover configuration—how to customize your node’s behavior and settings through the CDI (Configuration Description Information) interface.

Configuration & Persistence

Overview: Why Configuration Matters

In Chapter 3, we built a working OpenLCB node with a hardcoded node name (async_blink) and fixed event IDs. This is perfect for learning—you can compile once and run immediately. But production nodes need flexibility: different users want different names, different event IDs, different blink intervals to match their layout or preferences.

This is where configuration comes in. OpenLCB provides a standard way to store and modify node settings without recompiling firmware. Through tools like JMRI, users can:

  • Edit the node name and description
  • Modify event IDs
  • Adjust the blink interval (how fast the LED blinks)
  • Change other parameters

All changes persist across reboots, stored safely in the node’s flash memory.

How Configuration Works in OpenLCB

Recall from Chapter 3 that our node exposes three key concepts (detailed in the next section):

ConceptPurpose
ACDI (Automatic Configuration Description Information)Standardized data block storing node identity (manufacturer, model, hardware/software version, user name, description)
SNIP (Simple Node Information Protocol)Network protocol that broadcasts ACDI data so other nodes and tools can discover and identify this node
CDI (Configuration Description Information)Schema describing what configuration options the node has and how to edit them via tools like JMRI

In this chapter, we’ll explore:

  1. Storage Model — Where configuration lives, how ACDI/SNIP/CDI fit together, and the binary file format
  2. Editing in JMRI — How to change node name and persist changes
  3. Factory Reset Behavior — What happens on first boot
  4. Persistence Details — How data survives reboots and power loss
  5. Adding Configurable Settings — Make blink interval editable via JMRI
  6. Configuration Versioning — Evolving your schema safely as you add more settings

What We’re Covering

This chapter combines conceptual understanding with hands-on implementation. We’ll:

  • Part 1 (Sections 1-4): Understand how configuration storage works, edit node name via JMRI, see changes persist
  • Part 2 (Sections 5-6): Make the blink interval configurable, implement persistence in code, handle schema versioning

By the end, you’ll have a production-ready configuration system that lets users customize your node’s behavior through JMRI without recompiling.

Storage Model: Where Configuration Lives

The Configuration File Structure

When async_blink runs on ESP32, it creates a configuration file in SPIFFS (flash storage) at /spiffs/openlcb_config. This file is organized by memory offset—different ranges of bytes store different types of data.

graph TD
    A["Offset 0-127: User Info<br/>Node name, description (editable via JMRI)"]
    B["Offset 128+: Internal Config<br/>Node-specific settings (currently unused in v0.1)"]
    C["Metadata: Version, CRC<br/>CANONICAL_VERSION for schema tracking"]
    
    A --> B
    B --> C
    
    style A fill:#e1f5ff
    style B fill:#f3e5f5
    style C fill:#fff9c4

Binary File Format

The /spiffs/openlcb_config file is binary, not text. This is important to understand because:

  • Offset-based layout: Different types of data (strings, integers, metadata) live at specific byte offsets. This requires a binary format where exact byte positions matter.
  • Direct serialization: OpenMRN’s accessors serialize C++ data structures directly into binary form, padding and aligning fields as needed.
  • Not human-readable: It’s raw binary data—you can’t read it as text or edit it directly.
  • CRC integrity: Binary metadata includes checksums to detect corruption.

Practical implication: You must use JMRI (or a similar LCC tool) to edit configuration. This is by design—it ensures configuration remains valid and in sync with the firmware’s expectations. The binary format protects against accidental corruption and keeps configuration data safe.

Three Concepts: ACDI, SNIP, and CDI

When you see these three terms in OpenLCB documentation, here’s what they mean:

ACDI (Automatic Configuration Description Information)

ACDI is a standardized data block that stores your node’s identity information:

  • Static fields: Manufacturer ID, model, hardware/software versions (compiled into firmware)
  • Dynamic/user fields: Node name and description (stored in SPIFFS, editable via JMRI)

Think of ACDI as your node’s “business card”—it describes what the node is.

SNIP (Simple Node Information Protocol)

SNIP is the protocol that allows the node to broadcast its ACDI data to the network. When JMRI or another tool connects and asks “who are you?” the node responds with SNIP data (pulled from ACDI).

CDI (Configuration Description Information)

CDI is the configuration schema—a blueprint describing:

  • What configuration options the node has (event IDs, intervals, etc.)
  • Their types (integer, string, event ID, etc.)
  • Default values and constraints

CDI tells tools how to configure the node, separate from identity.

How They Work Together

ConceptPurposeStored WhereExample
ACDINode identity & descriptionSPIFFS + firmwareManufacturer “OpenMRN”, User name “Kitchen Light”
SNIPProtocol to broadcast ACDI(generated from ACDI at runtime)“Who are you?” → SNIP response with ACDI
CDIConfiguration schema/blueprintCompiled in firmware“Node has EVENT_INTERVAL config (int, 100-10000ms)”

The config file at /spiffs/openlcb_config stores:

  • User ACDI data (offset 0-127): Node name and description
  • Node-specific config (offset 128+): Settings like event IDs, blink interval, etc.

OpenMRNLite automatically:

  • Responds to SNIP requests using ACDI
  • Exposes CDI to configuration tools
  • Handles persistence of both ACDI and config data

Understanding CANONICAL_VERSION

In config.h, you’ll see:

static constexpr uint16_t CANONICAL_VERSION = 0x0001;

This version number is not for your application—it’s for OpenMRN to track schema changes. Here’s why it matters:

  • First Boot: No config file exists yet. OpenMRN sees CANONICAL_VERSION = 0x0001 and initializes a new config file with this version number.
  • Later Boots: OpenMRN reads the version from the config file. If it matches CANONICAL_VERSION, everything is normal—use the config file as-is.
  • Version Mismatch: If you change CANONICAL_VERSION in your code and recompile, OpenMRN detects a mismatch and triggers a complete factory reset. This wipes the ENTIRE config file, including SNIP user data at offset 0-127 (your node name and description that users may have customized). Everything is reinitialized to defaults.

Important: Users lose their custom node name and any other persisted configuration when CANONICAL_VERSION changes. We won’t change it in this section, but later sections explore how to evolve configuration safely while preserving user data.

The CDI Segments

Recall from Chapter 3 that config.h defines configuration segments:

CDI_GROUP(ConfigDef, MainCdi());
  CDI_GROUP_ENTRY(ident, Identification);      // Static SNIP data: manufacturer, model, etc.
  CDI_GROUP_ENTRY(acdi, Acdi);                 // ACDI protocol marker (tells tools ACDI data exists)
  CDI_GROUP_ENTRY(userinfo, UserInfoSegment);  // User-editable name/description (configuration tool interface)
  CDI_GROUP_ENTRY(seg, AsyncBlinkSegment);     // Internal config (offset 128+)
CDI_GROUP_END();

Each segment serves a specific role:

  • ident: Static manufacturer data (Identification) — read-only in JMRI, compiled into firmware
  • acdi: ACDI protocol marker — signals to configuration tools that ACDI data is available. Does NOT expose data directly; instead acts as a tag for the SNIP protocol handler
  • userinfo: User-editable ACDI fields (User Name, Description) — what JMRI displays as the “User Info” tab for user customization
  • seg: Internal app config, stored at offset 128+

In JMRI’s LccPro configuration dialog, you’ll see:

  1. Identification tab (from ident: manufacturer, model, hardware/software versions)
  2. User Info tab (from userinfo: the name and description fields you can customize)

The acdi entry doesn’t appear as a separate tab—it’s a protocol marker that enables the SNIP data exchange behind the scenes.

Why Offsets Matter

The offset-based model gives you precise control over where data lives:

config.h shows:
  CDI_GROUP_ENTRY(userinfo, UserInfoSegment, Name("User Info"));
  CDI_GROUP_ENTRY(seg, AsyncBlinkSegment, Offset(128));
                                           ↑
                          Explicit offset for internal config

This means:

  • User name/description go to offset 0-127 (SNIP user data, completely wiped on factory reset and reinitialized to defaults from config.h)
  • Internal config starts at offset 128+ (completely wiped on factory reset, replaced with defaults)
  • When JMRI edits the user name, it writes directly to offset 0-127
  • When factory reset is triggered, both offset 0-127 and offset 128+ are wiped and reinitialized from the firmware defaults

Important: There are NO offsets preserved during factory reset except for the static SNIP data compiled into firmware (manufacturer, model, hardware/software versions). All config file data, including user customizations at offset 0-127, is lost.

Key Takeaway

Configuration is organized by memory offset in a SPIFFS file. Static data (manufacturer info) lives in firmware; dynamic data (node name) lives in flash. OpenMRN tracks schema with CANONICAL_VERSION to trigger a factory reset when the config schema changes (all-or-nothing reset, no field migration). The CDI segments map C++ structures to these memory offsets, giving JMRI a machine-readable map of what’s configurable.

Editing Configuration in JMRI

Step-by-Step: Edit Node Name in LccPro

One of the most common configuration tasks is renaming a node to something meaningful (e.g., “Test Blink Node” instead of “async_blink”). Here’s how it works:

1. Launch JMRI and Open the LccPro Tool

Start JMRI (5.12 or later) and navigate to:

Tools → LCC → LccPro

You should see a list of nodes currently on your LCC network. Your ESP32 node appears here, identified by its ACDI manufacturer and model information.

LccPro main window showing node list with async_blink

2. Select Your Node

In the node list, click on the node you want to configure (e.g., “async_blink”).

The details panel below the list now shows information for the selected node:

  • Node ID
  • Static SNIP Data: Manufacturer (OpenMRN), Model (async_blink), Hardware version (ESP32), Software version (1.00)
  • SNIP User Data: User name (currently async_blink), user description (ESP32 Blink demo)

LccPro node details, ACDI section

3. Open the Configuration Editor

Click the Configure button (or double-click the node).

LccPro opens a dialog showing the node’s CDI. You’ll see tabs for different sections:

  • Identification (read-only): Static SNIP data from firmware
  • User Info (editable): SNIP user name and description from config file
  • Settings (if present): Application-specific configuration

SCREENSHOT PLACEHOLDER: LccPro Configure dialog, tabs visible

4. Edit the User Name

Expand the User Info tab.

You’ll see the User Info tab with fields populated with the current values from the node (as shown in the previous step). Change the User Name to something meaningful, like Test Blink Node. You can also edit the description if you’d like.

LccPro User Info tab with edited name “Test Blink Node”

5. Write the Changes to the Node

After editing the User Name, you have two options:

  • Write individually: Click the Write button next to the User Name field to send just that change to the node
  • Write all changes: Click the Save Changes button at the bottom of the dialog to write all modified fields at once

Either way, LccPro sends the new user name to the ESP32 node via OpenLCB Configuration protocol:

sequenceDiagram
    actor User as You (JMRI)
    participant JMRI
    participant ESP32 as ESP32<br/>(OpenMRN)
    participant SPIFFS
    
    User->>JMRI: Click "Write"<br/>or "Save Changes"
    JMRI->>ESP32: Configuration write message<br/>(new user name)
    ESP32->>SPIFFS: Write new name<br/>to offset 0-127
    ESP32->>JMRI: Configuration write ACK<br/>(success)
    JMRI->>User: Changes saved!

For SNIP user data (Name and Description), the changes take effect immediately. There is no separate “apply” step needed.

6. Verify Persistence

Once saved, the new name persists across reboots:

  1. Immediate Check in LccPro: Click the Refresh button above the node list. This tells LccPro to ask all nodes for their current information. The node list should now show the updated name Test Blink Node instead of async_blink.

  2. Power Cycle Check: Power off the ESP32, wait a few seconds, power it back on. Reconnect JMRI to the node (or click Refresh again). The name is still Test Blink Node—it was saved to SPIFFS.

What Just Happened

When you edited and saved the name, OpenMRN did this:

  1. Received the new value via JMRI’s Configuration write protocol
  2. Wrote it to the config file at offset 0-127 (the SNIP user data area)
  3. Confirmed the write was successful back to JMRI

The next time the node boots, it reads this config file from SPIFFS and exposes the updated user name via SNIP protocol. The firmware itself didn’t change—only the data in flash storage.

SNIP user data (name and description) is part of the OpenLCB standard for node identification. Changes to these fields take effect immediately without needing an explicit “apply” step.

What This Enables

This workflow shows the power of configuration:

  • No recompilation needed to change the node name
  • Persistent across power cycles
  • Standard protocol (OpenLCB Configuration) that any LCC tool understands
  • Centralized in JMRI—all nodes configured from one place

Later sections of this chapter extend this pattern to make event IDs, blink interval, and other settings configurable too. The same workflow will apply.

Troubleshooting: Name Doesn’t Save

If you edit the name in JMRI but it doesn’t save:

  1. Check Connection: Ensure JMRI is still connected to the node (green indicator in LccPro)
  2. Check Permissions: The node’s CDI must declare the User Info segment as writable. In our example, it is.
  3. Check SPIFFS: The ESP32’s SPIFFS must have free space. If SPIFFS is full, writes fail silently. Check serial output for error messages.

See Chapter 3’s code-walkthrough section for details on how configuration is initialized.

Next: Application-Specific Configuration

This section covered editing SNIP user data (Name and Description), which are standardized fields handled automatically by OpenMRN. When you add application-specific configuration fields (like the blink interval in the next chapter), the workflow changes slightly—you’ll need to use the Update Complete button to tell the node to apply your custom configuration changes. See Chapter 4’s “Adding a Configurable Interval Setting” section for that workflow.

Factory Reset Behavior

What Happens on First Boot

When you flash async_blink to an ESP32 for the first time, the node goes through an initialization sequence. Understanding this sequence is key to understanding configuration.

Before First Boot

  • ESP32 has the async_blink firmware loaded
  • SPIFFS flash memory is either empty OR contains leftover config from a previous application
  • OpenMRN doesn’t know what to expect

First Boot: factory_reset() is Called

On startup, main.cpp calls:

void init_filesystem() {
  openlcb::FATFS_INIT();  // Mount SPIFFS
  
  // If no valid config file exists, create one
  if (!CheckFileExists(&driver, CONFIG_FILENAME)) {
    factory_reset(&driver, CONFIG_FILENAME, &config_file_content, 
                  0, sizeof(config_file_content));
  }
}

The factory_reset() function:

  1. Creates a new config file at /spiffs/openlcb_config
  2. Initializes all fields with default values
  3. Writes SNIP user data: User name = “async_blink”, description = “ESP32 Blink demo”
  4. Initializes internal config: Sets up offset 128+ with defaults
  5. Stores CANONICAL_VERSION: Version 0x0001 is written to config metadata

After factory_reset() completes:

  • Config file is ready to use
  • Node name is “async_blink” (the default from SNIP_NODE_NAME constant)
  • JMRI can now edit the user name via the User Info tab

What Gets Wiped and What Doesn’t

When factory_reset() is triggered:

DataWhat HappensReason
Static SNIP Data (manufacturer, model, hardware/software version)UntouchedLives in firmware, not in config file
SNIP User Data (user name, description)Completely wipedReset to defaults from config.h (e.g., “async_blink”)
Internal Config (offset 128+)Completely wipedReset to application defaults
User Customizations (node name set via JMRI)LostFactory reset erases all custom edits

This is important to understand: factory reset is all-or-nothing. Once triggered, users lose any custom node name or description they may have set through JMRI. There is no selective preservation of custom fields.

What Persists Across Reboots

Once factory_reset() runs and the config file exists, normal boots are much simpler:

  1. SPIFFS is mounted
  2. Config file is found → no factory reset needed
  3. User name and description are read from offset 0-127
  4. Internal config is read from offset 128+
  5. Node comes online with the same configuration as before

This is why editing the node name in JMRI persists across power cycles.

When Does Factory Reset Trigger Again?

Besides explicitly calling factory_reset() (or pressing a physical button), OpenMRN automatically triggers a reset if:

  • Config file is corrupted (CRC check fails)
  • CANONICAL_VERSION mismatch (firmware version differs from saved config version)

Important Warning: If you change CANONICAL_VERSION in your firmware (e.g., to add new configuration fields), the next boot will trigger a factory reset. Users will lose any custom node name or configuration they set through JMRI. This is a breaking change for deployed nodes.

Triggering a Factory Reset from JMRI

You can manually trigger a factory reset from the JMRI LccPro configure dialog without recompiling firmware or pressing buttons on the ESP32:

  1. Open LccPro and select your node
  2. Click Configure (or double-click the node)
  3. In the configure dialog, click the More… button
  4. Select Factory Reset

JMRI will send a factory reset command to the node via OpenLCB Configuration protocol. The node will immediately reinitialize its config file with factory defaults. This is useful for:

  • Testing: Quickly reset configuration during development
  • Troubleshooting: Recover from corrupted settings without physical access to the board
  • User Support: Remotely help users restore a node to factory defaults without recompiling

Practical Example: Factory Reset Reboot

When you trigger a factory reset (via JMRI or version mismatch), here’s what the serial console shows on the reboot:

rst:0xc (SW_CPU_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT)
configsip: 0, SPIWP:0xee
clk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00
mode:DIO, clock div:2
load:0x3fff0030,len:1184
load:0x40078000,len:13232
load:0x40080400,len:3028
entry 0x400805e4

=== OpenLCB async_blink ESP32 Example ===
Node ID: 0x050201020200
Event 0: 0x0502010202000000
Event 1: 0x0502010202000001

Initializing SPIFFS...
SPIFFS initialized successfully

Connecting to WiFi SSID: YourNetwork
.
WiFi connected!
IP Address: 192.168.1.100

Creating CDI configuration descriptor...
[CDI] Checking /spiffs/cdi.xml...
[CDI] File /spiffs/cdi.xml appears up-to-date (len 987 vs 987)
[CDI] Registering CDI with stack...
Initializing OpenLCB configuration...

Starting OpenLCB stack...
Starting executor thread...
Starting TCP Hub on port 12021...
TCP Hub listening. JMRI can connect to this device on port 12021

OpenLCB node initialization complete!
Entering run mode - will alternate events every 1 second

Allocating new alias C41 for node 050201020200
Produced event: 0x0502010202000001 (state: 1)
Produced event: 0x0502010202000000 (state: 0)
Produced event: 0x0502010202000001 (state: 1)
...

Important: Notice there is no console message when the reset is triggered. The reset happens silently from an external trigger. The boot diagnostics (ROM messages about rst:0xc, clock div, etc.) indicate a software reset occurred. Then the normal initialization proceeds.

After factory reset, your config file is re-initialized with defaults:

  • SNIP User Name: “async_blink” (from SNIP_NODE_NAME constant)
  • SNIP User Desc: “ESP32 Blink demo” (from SNIP_USER_DESCRIPTION constant)
  • Any custom node name set via JMRI is lost

What Happens After Factory Reset

After factory reset, subsequent boots proceed normally. The config file already exists and is valid, so no factory reset runs again unless:

  • The config file becomes corrupted
  • The CANONICAL_VERSION in firmware differs from the saved version
  • You explicitly trigger another reset via JMRI Configure dialog

Key Takeaway

factory_reset() runs on first boot or when triggered by JMRI, version mismatch, or config corruption to initialize a valid config file with SNIP defaults. After that, the config persists across every reboot. JMRI can edit the user name, which updates the config file while preserving other settings.

Adding a Configurable Interval Setting

In this section, we’ll extend the async_blink example from Chapter 3 to make the blink interval configurable via JMRI. This demonstrates how to:

  1. Add a new configuration field to the CDI schema
  2. Implement apply_configuration() to read the field and cache it
  3. Use the cached value in your application loop
  4. Test configuration changes in real-time with LccPro

Code Changes to config.h

Starting from the Chapter 3 version, we’ll add a configurable blink_interval field to the CDI. Because we’re adding a new field to the configuration schema, we must also bump CANONICAL_VERSION (from 0x0001 to 0x0002). When you upload this new code, OpenMRN will detect the version mismatch and trigger a factory reset, which wipes the old config file and initializes it with the new schema.

Here’s what changes:

CDI_GROUP(AsyncBlinkSegment, Segment(MemoryConfigDefs::SPACE_CONFIG), Offset(128));
CDI_GROUP_ENTRY(internal_config, InternalConfigData);
+CDI_GROUP_ENTRY(blink_interval, Uint16ConfigEntry,
+                Default(1000),
+                Min(100),
+                Max(30000),
+                Name("Blink Interval"),
+                Description("Milliseconds between alternating events (100-30000)"));
CDI_GROUP_END();

What this means:

  • Uint16ConfigEntry: A 16-bit unsigned integer field
  • Default(1000): Initial value is 1000 milliseconds (1 second)
  • Min(100), Max(30000): JMRI will enforce these limits
  • Name() and Description(): Labels shown in the JMRI configuration dialog

The field is stored at offset 128+ in the configuration file (after the SNIP user data at offset 0-127).

Bump CANONICAL_VERSION

Because we changed the configuration schema (added a new field), we must bump the version number. This tells OpenMRN that the configuration file format has changed. On the next boot, OpenMRN will detect the version mismatch and trigger a factory reset:

-static constexpr uint16_t CANONICAL_VERSION = 0x0001;
+static constexpr uint16_t CANONICAL_VERSION = 0x0002;

When you upload this new code:

  1. OpenMRN reads the old config file’s version (0x0001)
  2. Compares it to the new CANONICAL_VERSION (0x0002)
  3. Detects a mismatch and triggers factory reset
  4. Calls your factory_reset() method to write defaults for the new schema
  5. Calls apply_configuration() to load the newly-initialized config file

This prevents data corruption from partially-initialized fields. The cost is that users’ old configuration is wiped (factory reset), but the new schema is guaranteed to be valid.

Full config.h with changes:

#ifndef _ASYNC_BLINK_CONFIG_H_
#define _ASYNC_BLINK_CONFIG_H_

#include "openlcb/ConfigRepresentation.hxx"
#include "openlcb/MemoryConfig.hxx"

namespace openlcb {

extern const SimpleNodeStaticValues SNIP_STATIC_DATA = {
    4,               // Version
    "OpenMRN",       // Manufacturer
    "async_blink",   // Model
    "ESP32",         // Hardware version
    "1.00"           // Software version
};

static const char SNIP_NODE_NAME[] = "async_blink";
static const char SNIP_NODE_DESC[] = "ESP32 Blink demo";

/// Version number for the configuration structure
static constexpr uint16_t CANONICAL_VERSION = 0x0002;

CDI_GROUP(AsyncBlinkSegment, Segment(MemoryConfigDefs::SPACE_CONFIG), Offset(128));
CDI_GROUP_ENTRY(internal_config, InternalConfigData);
CDI_GROUP_ENTRY(blink_interval, Uint16ConfigEntry,
                Default(1000),
                Min(100),
                Max(30000),
                Name("Blink Interval"),
                Description("Milliseconds between alternating events (100-30000)"));
CDI_GROUP_END();

CDI_GROUP(ConfigDef, MainCdi());
CDI_GROUP_ENTRY(ident, Identification);
CDI_GROUP_ENTRY(acdi, Acdi);
CDI_GROUP_ENTRY(userinfo, UserInfoSegment, Name("User Info"));
CDI_GROUP_ENTRY(seg, AsyncBlinkSegment, Name("Settings"));
CDI_GROUP_END();

} // namespace openlcb

#endif // _ASYNC_BLINK_CONFIG_H_

Code Changes to main.cpp

Now we’ll modify main.cpp to:

  1. Add a global cache variable for the interval
  2. Update apply_configuration() to read the interval from the config file
  3. Update factory_reset() to write default interval to the config file
  4. Use the cached value in the loop

Add the Cache Variable

// State variable to track which event to send
bool event_state = false;

-// Timing for event production (1 second = 1000 milliseconds)
-unsigned long last_event_time = 0;
-const unsigned long EVENT_INTERVAL = 1000;
+// Timing for event production - cached from configuration
+unsigned long last_event_time = 0;
+unsigned long event_interval = 1000;  // Default, will be read from config

This global event_interval variable will be updated whenever the configuration changes.

Update apply_configuration()

Replace the stub apply_configuration() method in the FactoryResetHelper class:

class FactoryResetHelper : public DefaultConfigUpdateListener
{
public:
    UpdateAction apply_configuration(int fd, bool initial_load,
                                     BarrierNotifiable *done) OVERRIDE
    {
        AutoNotify n(done);
-        // In v0.1, we don't handle runtime config changes yet.
-        // Real nodes would persist changes here when the user modifies
-        // configuration through JMRI. See Chapter 5 for implementation.
+        // Read the blink interval from config file and update global variable
+        event_interval = cfg.seg().blink_interval().read(fd);
+        Serial.printf("Configuration updated: blink_interval = %lu ms\n", event_interval);
+        
         return UPDATED;
    }

    void factory_reset(int fd) override
    {
        // Called on first boot to initialize the configuration file.
        // Write initial SNIP dynamic data (node name and description).
         cfg.userinfo().name().write(fd, openlcb::SNIP_NODE_NAME);
         cfg.userinfo().description().write(fd, openlcb::SNIP_NODE_DESC);
+        
+        // Initialize application settings with defaults
+        cfg.seg().blink_interval().write(fd, 1000);  // Default 1 second
+        Serial.println("Factory reset: wrote default blink_interval = 1000 ms");
     }
} factory_reset_helper;

What happens when apply_configuration() is called:

  • On first boot with the new schema (CANONICAL_VERSION bumped): OpenMRN detects the version mismatch, triggers factory_reset() to write defaults, then calls apply_configuration() to read those freshly-written defaults
  • On JMRI config change: Reads the new interval value that JMRI just wrote to the config file
  • Always returns UPDATED: Tells OpenMRN the change was successfully applied

The key sequence on first boot with schema changes: factory_reset(fd) is called first (writes 1000 to the file), then apply_configuration(fd) is called (reads 1000 from the file).

Use the Cached Value in Loop

In the loop() function, change the hardcoded constant to use the cached variable:

void loop() {
  // CRITICAL: Must call openmrn.loop() frequently to process messages
  openmrn.loop();
  
-  // Check if it's time to produce an event (every 1 second)
+  // Check if it's time to produce an event
  unsigned long current_time = millis();
-  if (current_time - last_event_time >= EVENT_INTERVAL) {
+  if (current_time - last_event_time >= event_interval) {
     // Alternate event state
     event_state = !event_state;

The loop now uses event_interval (the cached variable) instead of EVENT_INTERVAL (the hardcoded constant). When apply_configuration() updates the global, the loop automatically uses the new value on the next iteration.

Update the Setup Message

Also update the startup message to reference the configurable variable:

void setup() {
  init_serial();
  init_filesystem();
  init_network();
  init_openlcb_stack();
  init_tcp_hub();
  
  Serial.println("OpenLCB node initialization complete!");
-  Serial.println("Entering run mode - will alternate events every 1 second\n");
+  Serial.printf("Entering run mode - will alternate events every %lu ms\n", event_interval);
  
  last_event_time = millis();
}

Testing with LccPro

Now that the code is modified, compile, upload to your ESP32, and test:

Step 1: Verify Initial Boot Output

Important: Because we bumped CANONICAL_VERSION from 0x0001 to 0x0002, the first boot with this new code will trigger a factory reset. This wipes the old config file and initializes it with the new schema. You’ll see factory reset messages on the serial output.

Open the Serial Monitor (or PlatformIO Monitor). You should see:

Initializing SPIFFS...
SPIFFS initialized successfully

Connecting to WiFi SSID: Socha_IoT
.
WiFi connected!
IP Address: 192.168.1.100

Creating CDI configuration descriptor...
[CDI] Checking /spiffs/cdi.xml...
[CDI] File /spiffs/cdi.xml appears up-to-date (len 1172 vs 1172)
[CDI] Registering CDI with stack...
Initializing OpenLCB configuration...
Factory reset: wrote default blink_interval = 1000 ms

Starting OpenLCB stack...
Starting executor thread...
OpenLCB stack initialized successfully
Configuration updated: blink_interval = 1000 ms
Starting TCP Hub on port 12021...
TCP Hub listening. JMRI can connect to tListening on port 12021, fd 48
his device on port 12021
OpenLCB node initialization complete!
Entering run mode - will alternate events every 1000 ms
Allocating new alias C41 for node 050201020200
Produced event: 0x0502010202000001 (state: 1)

What to verify:

  • Factory reset: wrote default blink_interval = 1000 ms — Confirms version mismatch detected and factory reset triggered
  • Configuration updated: blink_interval = 1000 ms — Confirms apply_configuration() was called and read the default value
  • Entering run mode - will alternate events every 1000 ms — Confirms event_interval global was set from config
  • Produced event: messages at ~1 second intervals — Confirms timing is working

If you see these messages, the configuration system is working correctly. You’re ready to test changing the interval via JMRI.

Step 2: Change Interval in LccPro

  1. Connect JMRI to your ESP32 node (via TCP on port 12021)
  2. Open Tools → LCC → LccPro
  3. Find your node in the list
  4. Click Configure (or double-click the node)
  5. Find the Settings tab and locate Blink Interval field
  6. Change the value (e.g., from 1000 to 2000 milliseconds)
  7. Click Write to send the new value

LccPro Configure dialog with Settings tab, Blink Interval field visible

Step 3: Click Update Complete

This is the critical step! Unlike SNIP user data (Name and Description, which take effect immediately), application-specific configuration fields like blink_interval need an explicit Update Complete step.

Here’s why: When you click Write, JMRI sends the new value to the ESP32 and it’s saved to SPIFFS. But OpenMRN doesn’t immediately call your apply_configuration() method. This allows you to make multiple writes in JMRI (e.g., change interval AND add other settings) before the node processes them all together.

When you click Update Complete, you’re telling the node: “All changes are sent; now apply them.” OpenMRN then calls your apply_configuration() method, which reads the new interval from the config file and updates your global cache variable.

Click the Update Complete button at the bottom of the Configure dialog.

LccPro Configure dialog showing Update Complete button

Step 4: Observe the Change

Check the Serial Monitor. You should see:

Produced event: 0x0502010202000000 (state: 0)
Produced event: 0x0502010202000001 (state: 1)
Configuration updated: blink_interval = 2000 ms
Produced event: 0x0502010202000000 (state: 0)
Produced event: 0x0502010202000001 (state: 1)

The node’s event frequency has changed without recompiling or rebooting. The new interval is now cached in the global variable and will persist across power cycles (the new value is saved to SPIFFS).

Step 5: Verify Persistence

Power off the ESP32, wait a few seconds, then power it back on. Open Serial Monitor again:

Creating CDI configuration descriptor...
[CDI] Checking /spiffs/cdi.xml...
[CDI] File /spiffs/cdi.xml appears up-to-date (len 1172 vs 1172)
[CDI] Registering CDI with stack...
Initializing OpenLCB configuration...

Starting OpenLCB stack...
Starting executor thread...
OpenLCB stack initialized successfully
Starting TCP Hub on port 12021...
Configuration updated: blink_interval = 2000 ms
TCP Hub listening. JMRI can connect to this device on port 12021
OpenListening on port 12021, fd 48
LCB node initialization complete!
Entering run mode - will alternate events every 2000 ms
Allocating new alias C41 for node 050201020200
Produced event: 0x0502010202000001 (state: 1)

What to verify:

  • Configuration updated: blink_interval = 2000 ms — The new value was read from the persisted config file
  • Entering run mode - will alternate events every 2000 ms — The cached interval reflects the new value you set

The interval persists even after power cycling!

How This Works: The Configuration Lifecycle

graph TD
    A["ESP32 Boots"]
    B["ConfigUpdateFlow opens config file"]
    C["apply_configuration called with initial_load=true"]
    D["Reads blink_interval from file, updates event_interval global"]
    E["Loop runs using event_interval"]
    F["User changes interval in JMRI"]
    G["JMRI sends config write to ESP32"]
    H["ESP32 writes new value to config file"]
    I["apply_configuration called with initial_load=false"]
    J["Reads new blink_interval, updates event_interval global"]
    K["Loop uses new interval on next iteration"]
    
    A --> B --> C --> D --> E
    F --> G --> H --> I --> J --> K
    K -.->|next config change| F

The key insight: apply_configuration() is called automatically by OpenMRN whenever configuration changes (at boot, when JMRI updates it, or when factory reset is triggered). You don’t need to manually check for changes or read the config file in your loop—the framework handles it for you.

Summary

You now have a fully functional configurable OpenLCB node running on ESP32 with WiFi/TCP transport:

  • Configuration storage: Values persisted to SPIFFS across power cycles
  • JMRI integration: Seamless configuration editing via LccPro
  • Dynamic updates: Changes applied without recompiling or rebooting
  • Versioning: Safe schema evolution via CANONICAL_VERSION
  • Factory reset: Automatic initialization of new schema fields

This completes the WiFi/TCP foundation for Chapter 3 and demonstrates configuration best practices covered in Chapter 4.

What’s Next: CAN Transport

In the next chapter, you’ll replace WiFi/TCP with CAN hardware transport. This is the native transport that LCC nodes use in production systems. You’ll modify the async_blink example to use CAN instead of WiFi while keeping the same configuration and event handling code.

The configuration system you’ve learned in this chapter—CDI, versioning, persistence—works identically regardless of transport. Only the communication layer changes.