A standalone Java daemon that provides a simple, clean API for controlling DCC command stations by leveraging JMRI's rich controller support without the complexity of JMRI's full object model and data structures.
This project is licensed under the GNU General Public License version 2 (GPL v2). See LICENSE.txt for details.
This license is compatible with JMRI (which also uses GPL v2), allowing dcc-io-daemon to link with JMRI's libraries.
DCC IO Daemon uses JMRI (Java Model Railroad Interface) for controller communication. JMRI is free software licensed under GPL v2, developed by the JMRI community. See LICENSE.txt for full acknowledgment details.
JMRI provides excellent support for a wide variety of DCC command stations through its jmrix layer, but using JMRI directly means dealing with its complex object model, data structures, and dependencies. This project bypasses all of that complexity by:
- Using JMRI's controller support via abstract interfaces - We leverage JMRI's
jmriximplementations (XpressNet, DCC++, NCE, etc.) without needing to understand or interact with JMRI's internal data model - Providing simple, DCC-spec-focused APIs - Clean interfaces for throttles and accessories that follow the DCC specification, not JMRI's object hierarchy
- Minimal dependencies - Only what's needed for controller communication, not the full JMRI ecosystem
- RESTful API - Language-agnostic HTTP/JSON interface perfect for integration with other systems
Perfect for headless deployments on Raspberry Pi or other embedded systems where you want DCC control without the overhead of a full JMRI installation.
- Auto-Discovery: Automatically detects and connects to USB/serial controllers using vendor/product IDs
- Controller Role Assignment: Assign controllers to handle throttles and/or accessories via web UI
- Web-Based Configuration: Modern web interface for managing connections and testing controllers
- RESTful API: Clean JSON API for integration with other applications
- Real-Time Console: Live message monitoring with decoded and raw hex output
- Multi-Controller Support: Handle multiple controllers with automatic role assignment
- Platform Agnostic: Works on Linux, Windows, and macOS
- Hornby Elite / XpressNet (
xnet-elite) - Serial/USB - Tested- See XNET_ELITE_IMPLEMENTATION.md for details on the special implementation
- NCE PowerCab Serial (
nce-serial) - Serial/USB - Untested - NCE PowerCab USB (
nce-usb) - USB - Untested - DCC++ Ethernet (
dccpp-ethernet) - Network connection - Untested
- Java 11 or higher
- Maven 3.6+
- JMRI 5.13.6+ (either pre-built JAR or source)
If you have a pre-built jmri.jar:
cd dcc-io-daemon
mvn clean packageThe pom.xml is configured to use the pre-built JAR via system scope.
If you only have JMRI source code:
cd ../JMRI-5.13.6
ant
jar -cf jmri.jar -C java/build/classes .
mvn install:install-file -Dfile=jmri.jar -DgroupId=org.jmri -DartifactId=jmri -Dversion=5.13.6 -Dpackaging=jar
cd ../dcc-io-daemon
mvn clean packageThe provided build scripts automatically detect and use pre-built JAR or build from source:
- Linux/Mac:
./build.sh - Windows:
build.bat
java -jar target/dcc-io-daemon-0.1.0-SNAPSHOT-jar-with-dependencies.jar [port]- If
portis omitted, the daemon listens on port9000by default - The web UI will be available at
http://localhost:9000/
If using system scope for JMRI, you may need to add JMRI to the classpath:
java -cp "target/dcc-io-daemon-0.1.0-SNAPSHOT-jar-with-dependencies.jar:../JMRI/jmri.jar:../JMRI/lib/*" org.dccio.daemon.DccIoDaemon [port]The daemon includes a comprehensive web-based interface accessible at http://localhost:9000/.
- Auto-Discovery: Scan for connected controllers automatically
- Connection Management: Create and manage controller connections
- Controller Role Assignment: Assign controllers to handle throttles and/or accessories
- Throttle Control: Test throttle functionality (speed, direction, functions F0-F12)
- Accessory Control: Test turnout/accessory control
- Live Console: Monitor all messages with decoded and raw hex output
- Command Station Info: View version, model, and power status
- Click "🔍 Auto-Discover Controllers" to scan for connected devices
- Select a detected device or manually configure:
- Connection ID (e.g.,
elite1) - Controller Type (e.g.,
Hornby Elite / XpressNet) - Port (for serial/USB) or Host/Port (for network)
- Baud Rate and Flow Control (for serial connections)
- Connection ID (e.g.,
- Click "Create Connection"
Controllers can be assigned to one or both roles:
- Throttles: Handles locomotive control (speed, direction, functions)
- Accessories: Handles turnout/accessory control
If only one controller is connected, it's automatically assigned both roles. If multiple controllers are present, you can assign roles via checkboxes in the connections list.
GET /health- Health check ({"status":"ok"})GET /connections- List all active connections with status and roles
-
POST /connections/create?id={id}&systemType={type}&portName={port}&...- Create a new connection- Query parameters:
id(required): Connection identifiersystemType(required): Controller type (e.g.,xnet-elite,nce-usb)portName(serial): Serial/USB port (e.g.,/dev/ttyACM0,COM3)host(network): Hostname/IP for network connectionsport(network): Port number for network connectionsbaudRate(serial, optional): Baud rate (default: 9600, Elite: 19200)flowControl(serial, optional):noneorrtsctsuserName(optional): User name for the connectionsystemPrefix(optional): System prefix
- Query parameters:
-
POST /connections/requestVersion?id={id}- Request version info from command station -
POST /connections/setRole?connectionId={id}&role={role}&enabled={true|false}- Assign/unassign controller rolerole:throttlesoraccessories
GET /api/systems- List supported controller typesGET /api/ports- List available serial/USB portsGET /api/discover- Discover connected controllers (auto-detection)
GET /api/events- Server-Sent Events (SSE) stream for real-time updates- Event types:
MESSAGE_RECEIVED,MESSAGE_SENT,THROTTLE_UPDATED,POWER_CHANGED,CONNECTION_STATE_CHANGED
- Event types:
The WebSocket endpoint listens on ws://<host>:<port+1>/json (port is HTTP port + 1). Messages are JSON envelopes:
type: resource name (e.g.,throttle,throttles,accessories)method: optional, defaults toget(supported:get,post,putwhere a handler allows)data: optional object payloadlist: optional alternative to list all items of a type (delegates to handlerlist)id: optional request identifier (string) - if provided, will be echoed back in the response for request/response correlation
Error responses use:
{ "type": "error", "data": { "code": 400, "message": "reason" }, "id": "req-1" }If a request includes an id, all responses (including errors) will include the same id to enable request/response correlation.
The server broadcasts JSON to all connected clients when changes occur:
statuswithmethod: "patch"– delta of changed connections. Only includes connections that changed (connect/disconnect, power status, or roles). Always includesid,connected,powerStatus, androlesfor changed connections.systemTypeandcommandStationare only included for new connections.throttlewithmethod: "patch"– only the changed throttle fields plus identifiers (throttle id, address, longAddress). Functions are sent as an object with numeric string keys (e.g.,{ "functions": { "0": true, "1": false } }). When speed is included, direction is always included.accessorieswithmethod: "patch"– array of changed accessories withnameandstate
Returns server status plus active connections (mirrors the REST /connections route).
Get status
{ "id": "req-1", "type": "status" }Response:
{
"id": "req-1",
"type": "status",
"data": {
"status": "ok",
"connections": [
{
"id": "elite1",
"systemType": "xnet-elite",
"connected": true,
"commandStation": { "version": "1.0" },
"powerStatus": "ON",
"roles": ["throttles", "accessories"]
}
]
}
}Set track power (POST):
Control track power for a specific connection:
{
"id": "req-2",
"type": "status",
"method": "post",
"data": {
"connectionId": "elite1",
"power": "OFF"
}
}Valid power values: "ON", "OFF", or "IDLE".
Response:
{
"id": "req-2",
"type": "status",
"data": {
"status": "ok",
"connections": [
{
"id": "elite1",
"connected": true,
"powerStatus": "OFF",
"roles": ["throttles", "accessories"]
}
]
}
}The server will automatically broadcast a status patch to all connected clients when power changes.
Status patch broadcasts:
The server automatically broadcasts status patches (type: "status", method: "patch") when:
- A controller connects or disconnects
- Power status changes (e.g., emergency stop button pressed, power turned off/on)
- Controller roles change
The patch format is a delta - only changed connections are included. Each changed connection always includes:
id- connection identifierconnected- connection statepowerStatus- current power statusroles- array of roles (e.g.,["throttles", "accessories"])
For new connections only, the patch also includes:
systemType- system type identifiercommandStation- command station information object
Example patch for a power status change:
{
"type": "status",
"method": "patch",
"data": {
"status": "ok",
"connections": [
{
"id": "elite1",
"connected": true,
"powerStatus": "OFF",
"roles": ["throttles", "accessories"]
}
]
}
}POST runs commands and stores accessory name/state pairs; GET returns stored states. PUT is not supported.
POST multiple accessories and commands
{
"id": "req-1",
"type": "accessories",
"method": "post",
"data": {
"accessories": [
{ "name": "signal1", "state": "green" },
{ "name": "point1", "state": "thrown" }
],
"commands": [
{ "address": 12, "state": "closed" },
{ "address": 1, "state": "thrown" }
]
}
}Successful response
{
"id": "req-1",
"type": "accessories",
"data": {
"accessories": [
{ "name": "signal1", "state": "green" },
{ "name": "point1", "state": "thrown" }
],
"commands": [
{ "address": 12, "closed": true },
{ "address": 1, "closed": false }
]
}
}Notes:
statefor commands acceptsclosedorthrown. Anything else yields 400.- Commands execute immediately through the accessory controller; command results are not stored.
- Accessory
name/statepairs are stored in memory for later GET/list retrieval.
GET single accessory
{ "id": "req-2", "type": "accessories", "data": { "name": "signal1" } }Response:
{ "id": "req-2", "type": "accessories", "data": { "name": "signal1", "state": "green" } }If unknown: { "id": "req-2", "type": "error", "data": { "code": 404, "message": "..." } }.
List all accessories (GET/list)
{ "id": "req-3", "list": "accessories" }or
{ "id": "req-3", "type": "accessories" }Response:
{
"id": "req-3",
"type": "accessories",
"data": [
{ "name": "signal1", "state": "green" },
{ "name": "point1", "state": "thrown" }
]
}Clients POST directly to a DCC address - throttles are created/retrieved automatically. Only one client can control speed/direction per address at a time (2 second timeout). Functions can be controlled by any client concurrently.
Get throttle status by address (GET):
{
"id": "req-1",
"type": "throttle",
"data": {
"address": 3
}
}Response:
{
"id": "req-1",
"type": "throttle",
"data": {
"throttle": "conn1:3:false",
"connectionId": "conn1",
"address": 3,
"longAddress": false,
"speed": 0.6,
"forward": true,
"functions": {
"0": false,
"1": true,
"2": false,
"3": false,
"4": false,
"5": false,
"6": false,
"7": false,
"8": false,
"9": false,
"10": false,
"11": false,
"12": false,
"13": false,
"14": false,
"15": false,
"16": false,
"17": false,
"18": false,
"19": false,
"20": false,
"21": false,
"22": false,
"23": false,
"24": false,
"25": false,
"26": false,
"27": false,
"28": false
}
}
}If no throttle exists for that address: type: "error", code: 404.
Control throttle by address (POST):
{
"id": "req-1",
"type": "throttle",
"method": "post",
"data": {
"address": 3,
"speed": 0.6,
"forward": true,
"functions": {
"0": true,
"1": true,
"2": false
}
}
}Response:
{
"id": "req-1",
"type": "throttle",
"data": {
"throttle": "conn1:3:false",
"connectionId": "conn1",
"address": 3,
"longAddress": false,
"speed": 0.6,
"forward": true,
"functions": {
"0": false,
"1": true,
"2": false,
"3": false,
"4": false,
"5": false,
"6": false,
"7": false,
"8": false,
"9": false,
"10": false,
"11": false,
"12": false,
"13": false,
"14": false,
"15": false,
"16": false,
"17": false,
"18": false,
"19": false,
"20": false,
"21": false,
"22": false,
"23": false,
"24": false,
"25": false,
"26": false,
"27": false,
"28": false
},
"updated": true
}
}If another client has control (409 Conflict):
{
"id": "req-1",
"type": "error",
"data": {
"code": 409,
"message": "Throttle busy: another client is controlling speed/direction for address 3"
}
}Notes:
address(required): DCC address (1-9999)longAddress(optional): true for long addresses (default: false)speed(optional): 0.0 to 1.0forward(optional): true for forward, false for reversefunctions(optional): Object with function numbers as string keys and boolean values (e.g.,{ "0": true, "1": false, "2": true }for F0 on, F1 off, F2 on)- Speed/direction: Exclusive lock per address (2 second timeout after last update)
- Functions: No locking - any client can control
List all throttles:
{ "id": "req-2", "list": "throttles" }Throttle responses return type of throttle or throttles with a data object/array. Errors use the standard error envelope.
Device auto-discovery is configured via src/main/resources/device-discovery-config.json. This file defines:
- Vendor ID and Product ID for USB device detection
- System type mapping
- Default configuration (baud rate, flow control, etc.)
- Description patterns for device matching
Example entry:
{
"name": "Hornby Elite",
"vendorId": "0x04d8",
"productId": "0x000a",
"systemType": "xnet-elite",
"config": {
"baudRate": "19200",
"flowControl": "none"
},
"descriptionPatterns": ["Hornby Elite", "XpressNet"]
}The daemon automatically:
- Scans for devices on startup
- Connects to detected controllers
- Assigns roles automatically (single controller = both roles, multiple = first gets both)
- Continuously monitors for new devices (every 5 seconds)
This daemon is designed to be a thin abstraction layer over JMRI's jmrix implementations. We:
- Reuse JMRI's controller implementations - All the protocol-specific code (XpressNet, DCC++, NCE, etc.) comes from JMRI
- Wrap with simple interfaces - Our
CommandStationConnection,ThrottleSession, andAccessoryControllerinterfaces hide JMRI's complexity - Avoid JMRI's object model - We don't use JMRI's
InstanceManager,NamedBeanhierarchies, or other complex data structures - Focus on DCC operations - The API is centered on DCC specification operations (throttle control, accessory control) rather than JMRI concepts
DccIoService: Main service interface for managing connections and throttlesCommandStationConnection: Simple interface for controller connections (wraps JMRI'sSystemConnectionMemoand adapters)ThrottleSession: Clean interface for locomotive control (wraps JMRI'sDccThrottle)AccessoryController: Simple interface for turnout/accessory control (wraps JMRI'sTurnoutManager)DeviceDiscoveryService: Platform-agnostic USB/serial device detection
Each connection implementation:
-
Wraps JMRI's
SystemConnectionMemoand protocol-specific adapters -
Provides a simple
CommandStationConnectioninterface -
Handles protocol-specific details internally (you don't need to know about XNet messages, NCE commands, etc.)
-
Exposes only DCC-standard operations (throttle speed/direction/functions, accessory states)
-
XNetEliteConnection: Hornby Elite / XpressNet (wraps JMRI'sXNetSystemConnectionMemo) -
NceSerialConnection: NCE PowerCab Serial (wraps JMRI'sNceSystemConnectionMemo) -
NceUsbConnection: NCE PowerCab USB (wraps JMRI'sNceSystemConnectionMemo) -
DccppConnection: DCC++ Ethernet (wraps JMRI'sDCCppSystemConnectionMemo)
Uses JDK's built-in HttpServer for minimal dependencies. Web UI is served from resources with separate HTML, CSS, and JavaScript files.
dcc-io-daemon/
├── src/main/java/org/dccio/
│ ├── core/ # Core interfaces and services
│ │ ├── impl/ # Implementation classes
│ │ └── events/ # Event system
│ └── daemon/ # HTTP server and web UI
├── src/main/resources/
│ ├── web/ # Web UI files
│ │ ├── index.html
│ │ └── static/
│ │ ├── style.css
│ │ └── app.js
│ └── device-discovery-config.json
└── pom.xml
- Create a new connection class implementing
CommandStationConnection - Extend
BaseCommandStationConnectionfor common functionality - Implement protocol-specific methods
- Register in
DccIoServiceImpl.createConnection() - Add device discovery entry to
device-discovery-config.json
- Check USB/serial port permissions (Linux: add user to
dialoutgroup) - Verify vendor/product IDs in
device-discovery-config.json - Check device description patterns match actual device name
- Verify port name is correct (
/dev/ttyACM0,COM3, etc.) - Check baud rate matches controller requirements (Elite: 19200)
- Ensure controller is powered on and connected
- Assign controller roles via web UI checkboxes
- Ensure at least one controller is connected and assigned the required role
This project wraps JMRI components. Please refer to JMRI's license for usage terms.
Contributions welcome! Please ensure:
- Code follows existing style
- New controllers are properly tested
- Documentation is updated