A portable, embedded-friendly implementation of the SIA OSDP (Open Supervised
Device Protocol) v2.2.2, written in freestanding C11 with a no_std-compatible
Rust wrapper on top.
The library is structured so that a Peripheral Device (PD), an Access Control Unit (ACU/CP), or a passive bus Monitor can each pull in only the code they actually need — no malloc, no globals, no OS dependencies in the core.
Iterations 1–3 done. The library now covers:
- Layer 1 framing — encode and decode every OSDP frame variant
(plain, with-SCB, MAC-bearing, encrypted-payload). Round-trips a
592-frame
libosdp-conformanceSecure Channel capture byte-for-byte. - Per-message codecs for the v2.2 baseline command/reply set (POLL, ID, CAP, LED, BUZ, OUT, TEXT, COMSET on the ACU side; ACK, NAK, PDID, PDCAP, RAW, KEYPAD, COM on the PD side), plus the SC handshake messages (CHLNG, SCRYPT, CCRYPT, RMAC_I).
- PD-side state machine (
osdp::pd): address filtering, sequence-number policing with byte-identical retransmit detection, online/offline tracking, optional Secure Channel handshake + operational SCS_15..18. - ACU-side state machine (
osdp::acu): multi-PD slot management, per-PD SQN, reply/timeout callbacks, optional Secure Channel handshake (fire-and-forget) and operational SCS_15..18 with automatic session-loss detection per spec D.1.4 / 5.7 / 5.9. - Live interop verified against both
libosdp-conformance
(via byte-level capture replay in tests) and Z-bit Systems'
OSDP.Net
ACUConsole(over a com0com null-modem pair, exercising POLL/ID/CAP plus SC).
See docs/PLAN.md for the phased roadmap and what's still in progress.
The included osdp-parser CLI consumes
OSDPCAP-format
captures (JSON-Lines) — both the libosdp-conformance space-separated
dialect and OSDP.Net's dash-separated dialect — and prints a one-line
summary per frame. Drop captures into tests/captures/ to register
them as integration tests automatically.
The included osdp-pd-mock CLI runs an osdp::pd instance on a real
or virtual serial port (Win32 or POSIX), with optional Secure Channel
(--sc=scbkd or --sc=scbk:HEX32). Useful for live interop validation
against a hardware ACU, OSDP.Net's ACUConsole, etc.
The companion osdp-acu-mock CLI runs an osdp::acu instance on a
serial port, driving an external PD (OSDP.Net's PDConsole, a
hardware PD, or another osdp-pd-mock over a com0com pair). Closes
the symmetric interop loop: pd-mock validates our PD against
third-party ACUs, acu-mock validates our ACU against third-party PDs.
The osdp-mcp server (Rust,
Model Context Protocol over
stdio or streamable HTTP) exposes the same PD-on-a-serial-port
behavior as osdp-pd-mock but driven through an AI agent. The
agent can bring a PD up, script its replies for any command code,
inject NAKs, wait for specific inbound commands, and read the
decoded wire history — useful for interactive ACU interop
investigation and for letting an agent author its own regression
tests against a controller.
The core library is split by message direction, not by role:
| Target | Contents | Linked by |
|---|---|---|
osdp::core |
CRC-16, checksum, frame decode/build, streaming push API, Secure Channel primitives (key derivation, cryptograms, custom CBC-MAC, AES-CBC payload encrypt/decrypt, frame wrap/unwrap) | everything |
osdp::messages |
One TU per command and per reply, each containing model + decoder + builder | PD, ACU, Monitor |
osdp::dispatch |
Optional switch-router from frame → typed message; pulls all codecs | Monitor only |
osdp::pd |
PD-side state machine: address filtering, command handler dispatch, sequence-number policing (byte-identical retransmit detection), online/offline tracking, optional Secure Channel handshake + operational SCS_15..18 | PD applications |
osdp::acu |
ACU-side state machine: multi-PD registration, command issuance, reply/timeout callbacks, sequence-number progression, optional Secure Channel handshake (fire-and-forget) + operational SCS_15..18 with session-loss detection | ACU applications |
A PD or ACU application calls per-message codec functions directly
(osdp_led_decode, osdp_pdid_build, etc.) and lets the linker garbage-collect
everything it doesn't reference. A Monitor links osdp::dispatch to decode
arbitrary traffic without writing its own switch.
Secure Channel is opt-in: a PD or ACU application that doesn't bind a
crypto vtable + key material via the osdp_*_set_sc_* API behaves
exactly like the iteration-2 (insecure) build, and the AES + RNG code
isn't pulled into the link. When SC is enabled, the consumer supplies
the AES-128 ECB primitive and the RNG via callbacks (osdp_sc_crypto_t)
— the core never vendors a crypto implementation. Tests use a vendored
copy of tiny-AES-c (Unlicense /
public domain) at vendor/tiny-aes/; production builds typically bind
mbedTLS, hardware AES, or BCryptGenRandom / /dev/urandom.
See CLAUDE.md for the full set of architectural constraints and coding rules.
core/ # C11 portable library, framing + codecs
├── include/osdp/ # public headers (consumers #include <osdp/...>)
├── src/
│ ├── shared/ # framing, integrity, stream — always linked
│ ├── commands/ # ACU→PD message codecs (one TU per command)
│ ├── replies/ # PD→ACU message codecs (one TU per reply)
│ └── dispatch/ # optional bulk dispatch helpers
└── CMakeLists.txt
pd/ # PD-side state machine + transport HAL
└── ... # exports osdp::pd
acu/ # ACU-side state machine + transport HAL
└── ... # exports osdp::acu
tools/
├── osdp-parser/ # OSDPCAP-aware CLI Monitor (osdpcap reader + main)
├── osdp-pd-mock/ # live PD on a serial port; pair with any ACU
│ # for interop validation
├── osdp-acu-mock/ # live ACU on a serial port; pair with any PD
│ # for interop validation
└── osdp-mcp/ # MCP server exposing an `osdp::pd` for
# agent-driven ACU testing (Rust;
# member of the rust/ workspace)
tests/
├── vendor/unity/ # vendored Unity test framework
├── captures/ # drop *.osdpcap files here for integration tests
└── test_*.c # Unity-based unit tests, run on host
vendor/ # 3rd-party code shared between tools and tests
└── tiny-aes/ # tiny-AES-c (Unlicense / public domain) —
# AES-128 ECB primitive for tests + osdp-pd-mock
rust/ # Rust workspace
└── osdp/ # `osdp-embedded` crate: typed Pd / Acu /
# frame / messages / sc + private FFI seam
# (mod sys, no_std, built via cc crate)
docs/ # design docs and plan; spec/ is gitignored
CMake-based; targets Windows host first (MSVC, clang, MinGW) and any embedded toolchain with a C11 compiler.
cmake -S . -B build
cmake --build build --config Debug
ctest --test-dir build -C Debug --output-on-failureUseful CMake options:
| Option | Default | Effect |
|---|---|---|
OSDP_BUILD_TESTS |
ON |
Build the Unity test suites under tests/. |
OSDP_BUILD_TOOLS |
ON |
Build host-side tools (osdp-parser). |
OSDP_SANITIZE |
OFF |
Build with AddressSanitizer (and UBSan on GCC/Clang) for memory-error debugging. Use a separate build directory; sanitizer-instrumented objects don't mix with the regular cache. |
To investigate a crash or suspected memory bug, configure a separate
build directory with OSDP_SANITIZE=ON:
cmake -S . -B build-asan -DOSDP_SANITIZE=ON
cmake --build build-asan --config Debug
ctest --test-dir build-asan -C DebugOn Windows, ASan needs clang_rt.asan_dynamic-x86_64.dll on PATH.
The DLL ships with Visual Studio under
VC\Tools\MSVC\<version>\bin\Hostx64\x64\ — add it to PATH or run from
a Developer PowerShell.
For a tight embedded build (core only), set both options to OFF and
link the osdp::core and osdp::messages (and optionally
osdp::dispatch) targets only — no compiler-options or test framework
get pulled in.
The osdp-embedded crate under rust/osdp/ wraps the C library
behind an idiomatic Rust API. no_std-compatible (requires alloc
for the trait-object based callbacks); compiles the C sources via
the cc crate at build time, so no
separate CMake step or libclang is needed and cargo build --target …
cross-compiles cleanly to any target the C library compiles on.
The crate offers Cargo features for selecting which roles get compiled in. A PD-only firmware build doesn't carry any ACU code — not at the Rust level, not at the C level:
# Pure PD firmware:
osdp-embedded = { version = "0.1", default-features = false, features = ["pd"] }
# ACU controller:
osdp-embedded = { version = "0.1", features = ["acu"] }
# Both (typical for tools, monitors, integration tests — also the default):
osdp-embedded = "0.1"Default features: ["std", "pd", "acu"].
Public API: osdp_embedded::{Error, Result, Transport, frame, messages, sc},
osdp_embedded::pd::{Pd, CommandHandler, Reply} (under pd),
osdp_embedded::acu::{Acu, ReplyHandler, TimeoutHandler, …} (under
acu), and the osdp_embedded::sc::ScCrypto trait for application-
supplied AES + RNG.
Build / test / run the loopback examples:
cargo build --manifest-path rust/Cargo.toml
cargo test --manifest-path rust/Cargo.toml
cargo run --manifest-path rust/Cargo.toml --example loopback
cargo run --manifest-path rust/Cargo.toml --example loopback_scSee docs/PUBLISHING.md for the release recipe
(version bump, vendoring the C tree, cargo publish).
build/tools/osdp-parser/osdp-parser my-capture.osdpcap
# or via stdin:
cat my-capture.osdpcap | build/tools/osdp-parser/osdp-parserosdp-pd-mock runs a real PD on a serial port so you can validate
this stack against an independent ACU implementation (Z-bit Systems'
OSDP.Net ACUConsole,
a hardware controller, etc.). The PD library itself stays freestanding;
the tool supplies a Win32 (CreateFile / ReadFile) or POSIX
(termios / O_NONBLOCK) serial transport adapter selected at build
time by CMake.
# Windows (com0com pair, ACUConsole on COM4):
$env:OSDP_INTEROP_PD_PORT = "COM3"
build\tools\osdp-pd-mock\Debug\osdp-pd-mock.exe --address 0 -v
# Linux (USB-RS485 dongle):
export OSDP_INTEROP_PD_PORT=/dev/ttyUSB0
build/tools/osdp-pd-mock/osdp-pd-mock --address 0 -vThe mock answers POLL with ACK, ID with a placeholder PDID, CAP with a
small default capability set, and ACKs LED / BUZ / OUT / TEXT /
COMSET / KEYSET. Run with --help for the full flag set. Secure
Channel mode (--sc=scbkd) is pending an AES-vendoring decision in
this build of the tool.
osdp-acu-mock is the companion tool that runs an osdp::acu on a
serial port and drives an external PD. It auto-sends ID once, CAP
once, then POLL on a configurable interval (default 500 ms) and
prints decoded replies / SC events / timeouts to stderr. SC mode is
identical to the PD tool (--sc=scbkd or --sc=scbk:HEX32); on
session loss it auto-restarts the handshake so a PD reboot doesn't
require restarting the ACU process.
# Drive a PD at address 0 on COM3 with the default install key:
$env:OSDP_INTEROP_ACU_PORT = "COM3"
build\tools\osdp-acu-mock\Debug\osdp-acu-mock.exe --address 0 --sc=scbkd -v
# Plaintext mode (no SC), faster polling:
build\tools\osdp-acu-mock\Debug\osdp-acu-mock.exe --address 0 \
--poll-interval 200 -vtools/osdp-mcp/ is a stdio
Model Context Protocol server
that wraps the Rust osdp_embedded::pd::Pd so an AI agent
(Claude Code, an IDE assistant, an autonomous test runner) can
drive a virtual PD against an ACU under test. The agent gets a
small set of tools for lifecycle, observation, and reply
scripting; the PD itself lives on a dedicated thread inside the
server so the !Send C state machine stays single-threaded
while the MCP layer remains async.
Built as part of the Rust workspace — no CMake step:
# Default build (RustCrypto AES backend for Secure Channel):
cargo build --manifest-path rust/Cargo.toml -p osdp-mcp
cargo test --manifest-path rust/Cargo.toml -p osdp-mcp
# Add the tiny-AES-c backend on top:
cargo build --manifest-path rust/Cargo.toml -p osdp-mcp --features crypto-tiny-aesCargo features select which AES backends get compiled into the
binary. At runtime, --crypto <name> picks among them.
| Feature | Default | AES source |
|---|---|---|
crypto-rustcrypto |
yes | pure-Rust aes crate (constant-time) |
crypto-tiny-aes |
no | vendored tiny-AES-c, compiled via build.rs from vendor/tiny-aes/ |
Both backends source random bytes from getrandom (the OS CSPRNG —
BCryptGenRandom / /dev/urandom / getentropy). Adding a third
backend (wolfCrypt, mbedTLS, hardware AES) is a single file behind
a new crypto-* feature.
The binary lands at rust/target/debug/osdp-mcp (or …/release/
with --release). Two transports are supported, selected at
runtime by --transport:
| Transport | Flag | Use |
|---|---|---|
| stdio (default) | --transport stdio |
One MCP client per process over stdin/stdout. What desktop clients (Claude Desktop, Claude Code) launch directly. |
| Streamable HTTP | --transport http |
Long-running HTTP server mounted at /mcp. Multiple concurrent clients share one PD. Useful for remote agents, CI runners, or sharing a PD across IDE sessions. |
For HTTP mode the bind address is --bind <addr>, defaulting to
127.0.0.1:8080. Loopback is the default by design — the
PD-control surface (script replies, inject events, force session
loss) is unauthenticated, so binding to a non-loopback address is
something the operator opts into deliberately. Put a reverse
proxy with auth in front if you need that.
Example for Claude Code (~/.claude.json), launching osdp-mcp on
stdio (the typical local-agent setup):
{
"mcpServers": {
"osdp-mcp": {
"command": "C:\\path\\to\\OSDP-Embedded\\rust\\target\\release\\osdp-mcp.exe",
"args": ["--crypto", "rustcrypto"]
}
}
}Example for running as a remote/shared HTTP server and pointing a client at it by URL:
# Start the server (loopback by default):
osdp-mcp --transport http --bind 127.0.0.1:8080
# In the client config, reference the endpoint instead of a command:
# "osdp-mcp": { "url": "http://127.0.0.1:8080/mcp" }--crypto may be omitted; the binary defaults to the first
compiled-in backend (RustCrypto when built with default features).
Run osdp-mcp --help for the full list of recognised backend
names in the current build.
Tools exposed today:
| Category | Tool | Purpose |
|---|---|---|
| Lifecycle | pd_configure |
Open a serial port and start the PD on it (with optional SC). |
pd_stop |
Tear the PD down (idempotent). | |
pd_status |
Structured snapshot: running / online / SC / last cmd+reply / event queue depth. | |
| Observation | get_log |
Cursor-paged decoded wire history (commands, replies, NAKs). Hides POLL/ACK heartbeat by default — pass exclude_codes: [] to see every entry. Response's suppressed block reports per-code hidden counts. |
get_log_summary |
Per-(direction, code) counts across the whole ring. No payloads — cheap "what's interesting in the log?" probe. | |
clear_log |
Drop log entries; cursor stays monotonic. | |
wait_for_command |
Block until an inbound command with a given code arrives. | |
| Scripting | set_reply_for |
Pin a static reply for a command code. |
set_reply_script |
Queue a sequence of replies (one-shot or cycling). | |
nak_next |
One-shot: make the next command of a given code reply NAK. | |
clear_overrides |
Drop every installed override. | |
| Events | inject_raw |
Queue a card-read; the PD reports RAW on its next POLL. |
inject_keypad |
Queue a keypad press; reports KEYPAD on the next POLL. | |
inject_local_status |
Queue a tamper/power change; reports LSTATR on the next POLL. | |
clear_events |
Drop every queued event. | |
| Faults | drop_next_n_replies |
Silently swallow the next N replies — exercises ACU offline detection. |
force_session_loss |
Rebuild the PD with the same params; ACU should re-handshake. | |
| Liveness | ping |
Banner string — confirms the server is reachable. |
Defaults (PDID vendor "ZBC", a small PDCAP set, baseline
POLL/ID/CAP/LED/BUZ/OUT/TEXT/KEYSET/COMSET behavior) match
osdp-pd-mock so a freshly-configured MCP-driven PD behaves
identically to the CLI tool. The agent overrides specific cmd
codes from there.
Secure Channel is enabled per-PD by passing sc_mode to
pd_configure. Two modes:
"scbkd"— bind the well-known install-time SCBK-D from the spec. Convenient for development against an ACU that supports handshake with the default key."scbk"+scbk_hex— bind a per-installation SCBK (32 hex chars = 16 bytes). The ACU side must hold the matching key.
Either mode also derives the cUID from the configured PDID per
spec D.4.3 and binds the chosen AES backend (--crypto) as the
PD's ScCrypto provider.
Event injection (RAW / KEYPAD / LSTATR) and fault injection
(drop_next_n_replies, force_session_loss) are wired up.
pd_status surfaces both event_queue_depth and drop_remaining
so an agent can confirm its setup before driving the ACU.
- Spec: SIA OSDP v2.2.2 (2024). Not redistributed in this repo;
docs/spec/is gitignored. Implementers should source the PDF directly from SIA. - Behavioral oracle: Z-bit Systems' OSDP.Net — full C# OSDP implementation by the same author. Used as a reference for command/reply layouts and as a known-good comparison target; no code is copied across.
OSDP-Embedded is dual-licensed:
- Open source: GNU General Public License v3.0 or later (GPL-3.0-or-later). Use this if your product is also distributed under a GPL-compatible license.
- Commercial: a paid license from Z-bit Systems for use in proprietary products that cannot comply with GPL terms (e.g. closed-source embedded firmware). See LICENSE-COMMERCIAL.md for inquiries.
You may choose either license. Every source file carries the SPDX identifier
GPL-3.0-or-later; commercial licensees receive a separate written
agreement that supersedes the GPL terms for their use.
See LICENSE.md for the full explainer.