A freestanding C11 implementation of SIA OSDP v2.2.2 for access control readers and controllers, with a no_std Rust wrapper.
The Open Supervised Device Protocol (OSDP) is the Security Industry Association's standard for communication between access control units and peripheral devices — card readers, keypads, and I/O boards on an RS-485 bus. Most available implementations assume a hosted operating system: dynamic allocation, threads, sockets, a filesystem. That rules them out on the microcontrollers those readers actually ship on.
OSDP-Embedded is written for that constrained end. The core is freestanding C11
with no malloc, no globals, no OS calls, and no I/O of its own — the application
owns every buffer and supplies the UART and AES primitives through callbacks. Each
message is a separate translation unit, so linker garbage collection drops the
codecs a device never references. A no_std-compatible Rust wrapper is published
alongside it as the osdp-embedded crate.
Key features:
- Peripheral Device (PD) state machine — address filtering, sequence-number policing, online/offline tracking, and Secure Channel, with the protocol mechanics handled for you rather than left to the application.
- Access Control Unit (ACU) state machine — multi-PD slot management, per-PD sequence numbers and reply timeouts, and Secure Channel with spec-mandated session-loss detection.
- Secure Channel (AES-128) — the full SCS_11..18 lifecycle. The consumer supplies AES and RNG through a small HAL; the library vendors no crypto.
- Complete v2.2 command and reply set outside the credential domain — 20 of 25 commands and 19 of 21 replies in Annex A.
- No allocation, no globals, no OS dependency in
core/,pd/, oracu/. Buffers are caller-owned and rebindable at run time; every function is reentrant. - Verified against independent implementations — byte-level capture replay against libosdp-conformance, and live serial interop against Z-bit Systems' OSDP.Net.
This release makes the PD side the primary supported surface: it answers every
v2.2 command an ACU can legally send outside the deferred credential set. The ACU
side is complete and tested — it drives the loopback integration suite and the
osdp-acu-mock interop tool — and is included in the same stability promise.
Development happens on main. Please open pull requests against it.
The core library needs a C11 compiler and four freestanding headers
(<stdint.h>, <stddef.h>, <string.h>, <stdbool.h>). It has no other
dependency, so it builds anywhere those exist — including bare-metal targets with
no libc.
What continuous integration verifies:
| Target | Toolchain | Coverage |
|---|---|---|
| Linux x86_64 | GCC | Full build and the complete test suite, every commit |
| Linux aarch64 | gcc-aarch64-linux-gnu |
Cross-compiled every commit (64-bit Raspberry Pi) |
| Windows x86_64 | MSVC | Library and tools built on every tagged release |
Windows builds with clang and MinGW are supported by the CMake presets and used during development, but are not part of the automated matrix.
Embedded toolchains consume the library through PlatformIO (see
Installation below). Two targets are verified by hand against the
published package — ESP32 Arduino (esp32dev, ACU) and STM32F103 bare-metal
Cortex-M3 (nucleo_f103rb, arm-none-eabi, PD). Neither is part of the
automated matrix; what CI does check on every commit is that the manifest still
matches the tree it describes (scripts/Test-Manifest.ps1).
For scale, the PD firmware in that Cortex-M3 build links at 10.7 KB of flash and 28 bytes of RAM — both role state machines compile into the archive and the linker drops the one you don't reference.
The Rust crate requires Rust 1.70 or later and is no_std-compatible; it needs
alloc for its trait-object callbacks. It compiles the C sources through the
cc crate at build time, so cargo build --target …
cross-compiles to any target the C library compiles on — no CMake step and no
libclang required.
Add the repository as a subdirectory and link the targets your role needs:
add_subdirectory(OSDP-Embedded)
target_link_libraries(my_reader_firmware PRIVATE
osdp::core # framing, stream decoder, CRC, checksum
osdp::messages # the command/reply codecs you reference
osdp::pd) # PD-side state machineAn ACU application links osdp::acu in place of osdp::pd. A passive bus monitor
adds osdp::dispatch for one-call bulk routing. For a minimal firmware build, turn
the host-side extras off:
cmake -S . -B build -DOSDP_BUILD_TESTS=OFF -DOSDP_BUILD_TOOLS=OFFPublished to the PlatformIO registry as
z-bit-systems/osdp-embedded:
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps = z-bit-systems/osdp-embedded@^1.0.1To track the repository instead — an unreleased fix, or a branch — pin a git revision:
lib_deps = https://github.com/Z-bit-Systems-LLC/OSDP-Embedded.git#v1.0.1Either way only core/, pd/ and acu/ reach your compiler; the tools, tests,
Rust crate and vendored code are never handed to it.
The manifest exports the public include directories, so #include "osdp/osdp_pd.h"
(or osdp/osdp_acu.h) works with no extra build_flags. Both role state machines
are compiled into the library archive and the linker keeps only the one your
firmware actually references.
Secure Channel needs an AES + RNG binding that the manifest deliberately does not ship — see Enabling Secure Channel below. On ESP32 the mbedTLS that already comes with the framework is the usual choice.
# PD-only firmware — no ACU code compiled, at either the Rust or the C level:
osdp-embedded = { version = "1.0", default-features = false, features = ["pd"] }
# ACU controller:
osdp-embedded = { version = "1.0", features = ["acu"] }
# Both (the default; typical for tools, monitors, and integration tests):
osdp-embedded = "1.0"Default features are ["std", "pd", "acu"].
This is a complete peripheral device: it answers the ACU's poll and NAKs anything it doesn't implement. Everything else is optional and stays dormant until you opt in.
#include "osdp/osdp_pd.h"
#include "osdp/osdp_commands.h"
#include "osdp/osdp_replies.h"
/* Your device logic. The PD has already validated the frame, filtered on
* address, and decrypted the payload if Secure Channel is running. */
static osdp_status_t on_command(void *user, uint8_t code,
const uint8_t *payload, size_t len,
osdp_pd_reply_t *reply)
{
switch (code) {
case OSDP_CMD_POLL: /* the ACU's heartbeat */
reply->code = OSDP_REPLY_ACK;
reply->payload = NULL;
reply->payload_len = 0;
return OSDP_OK;
default:
return OSDP_ERR_NOT_SUPPORTED; /* the library sends NAK 0x03 */
}
}
int main(void)
{
/* Three non-blocking callbacks over your UART. read() returns the byte
* count available now (0 is fine), write() must send all len bytes,
* now_ms() is a free-running millisecond counter. */
osdp_pd_transport_t transport = {
.read = uart_read, .write = uart_write, .now_ms = millis, .user = NULL
};
osdp_pd_t pd;
osdp_pd_init(&pd, 0x00); /* 7-bit PD address */
osdp_pd_set_transport(&pd, &transport);
osdp_pd_set_command_handler(&pd, on_command, NULL);
for (;;) {
osdp_pd_tick(&pd); /* never blocks: reads what's there, replies, returns */
/* yield to the rest of your main loop — sleep, WFI, run other tasks */
}
}osdp_pd_tick() does all the work: it drains available bytes, processes complete
frames addressed to this PD, calls your handler, and frames the reply back onto the
wire. Nothing in the library sleeps or blocks.
use osdp_embedded::messages::{OSDP_CMD_POLL, OSDP_REPLY_ACK};
use osdp_embedded::pd::{CommandHandler, Pd, Reply};
use osdp_embedded::{Error, Result};
struct Reader;
impl CommandHandler for Reader {
fn handle<'a>(&'a mut self, cmd_code: u8, _payload: &[u8]) -> Result<Reply<'a>> {
match cmd_code {
OSDP_CMD_POLL => Ok(Reply {
code: OSDP_REPLY_ACK,
payload: &[],
}),
_ => Err(Error::NotSupported), // the library sends NAK 0x03
}
}
}
let mut pd = Pd::new(0x00);
pd.set_transport(my_uart); // any type implementing osdp_embedded::Transport
pd.set_command_handler(Reader);
loop {
pd.tick();
}The borrowed Reply<'a> is how a reply payload can point straight into your own
storage — no copy, no allocation. examples/loopback.rs and examples/loopback_sc.rs
run a complete PD against a complete ACU in-process if you want to see it working
before wiring real hardware.
A reader reports credentials as a poll response: the read is queued, and the PD delivers it the next time the ACU polls. Storage is caller-owned, so queue depth is your choice, not the library's.
static uint8_t event_buf[256];
osdp_pd_set_event_queue(&pd, event_buf, sizeof event_buf);
/* ... later, from wherever your Wiegand/OSDP reader front-end runs: */
void on_card_presented(const uint8_t *bits, uint16_t bit_count)
{
uint8_t payload[64];
size_t written;
osdp_raw_t raw = {
.reader_no = 0,
.format_code = OSDP_RAW_FORMAT_WIEGAND,
.bit_count = bit_count,
.bit_data = bits,
.bit_data_len = (bit_count + 7u) / 8u,
};
if (osdp_raw_build(&raw, payload, sizeof payload, &written) == OSDP_OK)
osdp_pd_enqueue_event(&pd, OSDP_REPLY_RAW, payload, written);
}Notes: the queue is emptied automatically when the PD goes offline (spec
7.11/7.12) — delivering a card read from before an outage would have the ACU act on
a stale presentation. osdp_KEYPAD, osdp_FMT, and osdp_MFGREP queue the same way.
Secure Channel is opt-in. Bind a crypto vtable and a key; the handshake, MAC chain, and payload encryption are then transparent — your command handler keeps seeing plaintext.
/* You supply AES-128 ECB and an RNG. The library vendors no crypto:
* bind mbedTLS, a hardware AES block, BCryptGenRandom, /dev/urandom, … */
static const osdp_sc_crypto_t crypto = {
.aes128_ecb_encrypt = my_aes_encrypt,
.aes128_ecb_decrypt = my_aes_decrypt,
.rand_bytes = my_rand_bytes,
};
osdp_pd_set_sc_crypto(&pd, &crypto);
osdp_pd_set_sc_scbk(&pd, installation_key); /* 16 bytes from secure storage */Using wolfCrypt? A ready-made binding ships in
ports/wolfcrypt.
Configure with -DOSDP_PORT_WOLFCRYPT=ON (plus CMAKE_PREFIX_PATH pointing at a
wolfSSL built with -DWOLFSSL_AESECB=yes) and link osdp::port_sc_wolfcrypt:
static osdp_sc_wolfcrypt_t wc; /* must outlive the PD */
osdp_sc_crypto_t crypto = {0};
osdp_sc_wolfcrypt_aes128(&wc, &crypto); /* AES-128 encrypt + decrypt */
osdp_sc_wolfcrypt_rng(&wc, &crypto); /* opt-in: wolfCrypt's DRBG */
osdp_pd_set_sc_crypto(&pd, &crypto);The RNG is a separate call on purpose, so you can choose where RNG output comes from. See the wolfCrypt port guide for wolfSSL build settings and design notes.
Notes: store the SCBK in a secure element or protected flash region, never in
plain application flash. Use osdp_pd_set_sc_scbk_d() for the spec's well-known
install-time key during commissioning only. A PD holding an operational SCBK refuses
clear-text commands outside the discovery allowlist (osdp_ID, osdp_CAP,
osdp_COMSET) with NAK 0x06, per the spec's prohibition on interleaving unsecured
packets into a secure session.
The PD decodes inbound osdp_LED and osdp_BUZ commands into a resolved
display state — flash phase, temporary-override countdown, and permanent colour all
folded together — and calls you only when the displayed result actually changes.
Drive your hardware from the callback instead of reimplementing the spec's timing rules.
static void on_led_change(void *user, uint8_t reader_no,
uint8_t led_no, uint8_t color)
{
set_gpio_rgb(led_no, color); /* OSDP_LED_RED, _GREEN, _AMBER, … */
}
osdp_pd_set_led_handler(&pd, on_led_change, NULL);Notes: timer-driven transitions (flashing, temporary-override expiry) are
detected inside osdp_pd_tick(), so they require the transport's now_ms clock.
The buzzer works identically via osdp_pd_set_buzzer_handler(). The wire reply is
unchanged, so this is observe-only with no interop impact.
osdp_LSTAT, ISTAT, OSTAT, and RSTAT have spec-defined reply layouts and
application-defined values. Register a status provider and the library builds the
replies; you supply only the numbers.
static void read_local(void *user, uint8_t *tamper, uint8_t *power)
{
*tamper = tamper_switch_open() ? 1u : 0u;
*power = mains_lost() ? 1u : 0u;
}
static size_t read_inputs(void *user, uint8_t *out, size_t cap)
{
size_t n = input_count() < cap ? input_count() : cap;
for (size_t i = 0; i < n; i++)
out[i] = input_is_active(i) ? 1u : 0u;
return n;
}
static const osdp_pd_status_provider_t status = {
.local = read_local, .inputs = read_inputs,
};
osdp_pd_set_status_provider(&pd, &status, NULL);Notes: every member is independent and optional. A NULL member leaves that one
command falling through to your command handler, so you can adopt this incrementally.
osdp_acu_pd_slot_t slots[4];
osdp_acu_t acu;
osdp_acu_init(&acu, slots, 4);
osdp_acu_set_transport(&acu, &transport);
osdp_acu_set_reply_handler(&acu, on_reply, NULL);
osdp_acu_register_pd(&acu, 0, 0x00); /* slot 0 <- PD address 0 */
osdp_acu_send_command(&acu, 0x00, OSDP_CMD_POLL, NULL, 0);
for (;;)
osdp_acu_tick(&acu);Notes: one command may be outstanding per PD; osdp_acu_send_command returns
OSDP_ERR_NOT_SUPPORTED if the previous reply hasn't landed or timed out. The
application drives command scheduling — the ACU does not auto-poll.
| Term | Meaning |
|---|---|
| PD | Peripheral Device — the reader, keypad, or I/O board. Answers commands; never speaks unprompted. |
| ACU | Access Control Unit (also CP, control panel). Polls the bus and issues every command. |
| Secure Channel | AES-128 encryption and message authentication between one ACU and one PD, established by a four-message handshake. |
| SCBK / SCBK-D | Secure Channel Base Key: the per-installation key, and the spec's well-known default used only for commissioning. |
| SCS_11..18 | The secure-channel frame types: 11–14 carry the handshake, 15/16 authenticate empty messages, 17/18 authenticate and encrypt data-bearing ones. |
| Sequence number | A 2-bit counter cycling 1→2→3→1 that detects retransmits. Zero signals a connection reset. |
| Multi-part message | The spec-5.10 transport that splits a message too large for one frame across several. |
One build-time constant per role sizes every working buffer: OSDP_PD_BUF_LEN
(512) and OSDP_ACU_BUF_LEN (1440). Both are #ifndef-guarded, so a build can
override them without touching headers. osdp_pd_t is roughly 4.4 KB at the
defaults.
Buffers can also be rebound at run time to caller-owned storage with
osdp_pd_set_buffers(), which takes any subset. Use the sizing helpers —
osdp_frame_max_payload(), osdp_sc_max_payload(), and
osdp_pd_max_reply_payload() — instead of deriving framing overhead by hand.
The library reaches the outside world through two small callback vtables you implement:
| HAL | Callbacks | Typical binding |
|---|---|---|
osdp_pd_transport_t / osdp_acu_transport_t |
read, write, now_ms |
UART, USB CDC, TCP socket, or a test double |
osdp_sc_crypto_t |
aes128_ecb_encrypt, aes128_ecb_decrypt, rand_bytes |
mbedTLS, wolfCrypt, a hardware AES block, BCryptGenRandom, /dev/urandom |
An application that never binds a crypto vtable gets a plaintext-only build, with no AES or RNG code linked in.
Every fallible function returns osdp_status_t; there is no errno-style global
state. Decoders defend against truncated, oversized, and malformed input — they
report errors, they never invoke undefined behavior.
Your command handler's return value maps onto the spec's Table 47 NAK codes, so a rejection reaches the ACU as a real reply rather than a timeout:
| Handler returns | PD sends |
|---|---|
OSDP_OK |
the reply you filled in |
OSDP_ERR_NOT_SUPPORTED |
osdp_NAK 0x03 — unknown command |
OSDP_ERR_BAD_PAYLOAD / OSDP_ERR_BAD_LENGTH |
osdp_NAK 0x02 — bad length |
OSDP_ERR_INVALID_ARG |
osdp_NAK 0x09 — unable to process record |
OSDP_ERR_BUSY |
osdp_BUSY |
Some commands mutate library-owned state or mandate a specific reply, so the state
machine intercepts them and they never reach your handler: osdp_COMSET,
osdp_FILETRANSFER, osdp_ABORT, and osdp_ACURXSIZE. Optional hooks let you
participate where a decision is genuinely yours — vetoing a COMSET address change,
evaluating each file-transfer fragment, or honoring osdp_KEEPACTIVE. See the
PD guide
for each hook's contract.
CMakePresets.json ships debug, release, asan, and lib-only configurations.
The whole configure → build → test loop is one command:
cmake --workflow --preset debugOn Windows with MSVC, run from a Developer PowerShell for VS so cl and rc are
on PATH — the presets use the single-config Ninja generator. The Rust workspace
builds separately:
cargo test --manifest-path rust/Cargo.toml
cargo run --manifest-path rust/Cargo.toml --example loopback_scBefore pushing, ./scripts/Check-Code.ps1 runs every gate CI enforces — the
PlatformIO manifest check, CMake configure/build/ctest, then cargo fmt,
clippy -D warnings, workspace build/test, and the loopback examples — in one
invocation.
Tests use the vendored Unity framework
under tests/. Dropping an OSDPCAP capture into tests/captures/ registers it as a
CTest case automatically (re-run cmake after adding one).
Configuring with -DOSDP_PORT_WOLFCRYPT=ON (and an installed wolfSSL on
CMAKE_PREFIX_PATH) re-runs the entire Secure Channel suite against wolfCrypt as
test_*_wolfcrypt, next to the default tiny-AES run. CI does this on every build
against a pinned, cached wolfSSL.
To chase a suspected memory bug, the asan preset builds with AddressSanitizer
(and UBSan on GCC/clang) into its own build/asan/ directory, kept apart so
instrumented and plain objects never mix:
cmake --workflow --preset asanOn Windows, ASan needs clang_rt.asan_dynamic-x86_64.dll on PATH; it ships with
Visual Studio under VC\Tools\MSVC\<version>\bin\Hostx64\x64\.
Four host-side tools ship with the repository for validating against real hardware and third-party stacks:
| Tool | Purpose |
|---|---|
osdp-pd-mock |
Runs a real PD on a serial port. Pair with any ACU to validate this stack against it. |
osdp-acu-mock |
Runs a real ACU on a serial port, driving an external PD. Closes the symmetric interop loop. |
osdp-parser |
Reads OSDPCAP captures and prints a decoded line per frame. |
osdp-mcp |
A Model Context Protocol server exposing a virtual PD, so an AI agent can drive it against an ACU under test — scripting replies, injecting NAKs and card reads, and reading decoded wire history. Includes an optional browser view of the reader's LEDs, buzzer, and keypad. See the MCP guide. |
# Run a PD at address 0 with the default install key:
build/tools/osdp-pd-mock/osdp-pd-mock --address 0 --sc=scbkd -vBoth mocks accept --sc=scbkd or --sc=scbk:HEX32 for Secure Channel. Run any tool
with --help for its full flag set.
- PD Guide — the complete guide to building a peripheral device: every API call, the transport and crypto HALs, buffer sizing, file transfer, and Secure Channel.
- MCP Guide — driving a virtual PD from an AI agent to test an ACU: every tool, the transports, client configuration, and the browser reader view.
- wolfCrypt port guide —
using wolfCrypt for Secure Channel: wolfSSL build settings, the
ports/wolfcryptbinding, and how it is tested. - Public headers — each declaration carries the spec section it implements.
- Architecture and coding rules — the locked design decisions and the reasoning behind them.
- Roadmap — phased plan, what shipped when.
- Specification — SIA OSDP v2.2.2 (2024). Not redistributed here; source the PDF directly from the Security Industry Association.
Current development goals:
- Credential-domain messages: biometric, PIV data exchange,
osdp_GENAUTH/osdp_CRAUTH, and transparent smartcard mode (osdp_XWR/osdp_XRD). These ride the multi-part transport that already exists, so the remaining work is the messages themselves. - Multi-record messages and reply convenience helpers.
- ACU-side file transfer and auto-poll scheduling.
- The deferred halves of PD file transfer: the "finishing" idle-fragment
protocol and
FtUpdateMsgMaxthrottling. - A certifiable test harness.
The goal is to support every command and reply in the SIA OSDP v2.2.2 specification.
We welcome contributions! Please:
- Submit pull requests against the
mainbranch - Follow existing code style and conventions — see CLAUDE.md for the architectural rules that constrain the core
- Include tests for new features: at minimum a round-trip test and a negative (truncated or malformed input) test for any new codec
- Update documentation as needed
For collaboration inquiries or questions, contact us through Z-bit Systems, LLC.
Hardware Vendor Collaboration
We encourage OSDP hardware vendors to utilize this project to accelerate development of OSDP-related devices. Core functionality is under an open source license to help increase adoption rates of the OSDP standard. Contact Z-bit Systems, LLC for inquiries regarding commercial integration or custom development.
OSDP-Embedded is dual-licensed. You may use it under either, at your choice:
- Open source: GNU General Public License v3.0 or later (GPL-3.0-or-later) — for products that are themselves 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, such as closed-source embedded firmware. See LICENSING for inquiries.
Every source file carries the SPDX identifier GPL-3.0-or-later; a signed
commercial agreement supersedes those terms for its licensee. See
LICENSE.md
for the full explainer.
- Security Industry Association — for the OSDP standard and for publishing libosdp-conformance, whose captures validate our framing byte-for-byte.
- tiny-AES-c (Unlicense) — the AES-128 primitive used by tests and host tools. Production builds bind their own.
- wolfSSL — wolfCrypt backs the optional
ports/wolfcryptbinding. It is not vendored here. - Unity — the vendored test framework.
For questions, issues, or feature requests:
- Open an issue on GitHub Issues
- Contact Z-bit Systems, LLC
About Z-bit Systems, LLC
Z-bit Systems specializes in access control systems, physical security integration, and enterprise security software solutions. We provide commercial support, custom development, and consulting services. Learn more at z-bitco.com.