hdm-am
A Rust client for the Armenian fiscal cash register protocol — the spec published by the State Revenue Committee of Armenia (ՊԵԿ) for integrating external (commercial) software with fiscal cash registers (Հսկիչ Դրամարկղային Մեքենա — HDM, ՀԴՄ).
The workspace contains four crates:
hdm-am— the library: wire framing, encryption, and one typed request/response per operation.hdm-am-cli— a thin command-line tool (binaryhdm) that maps subcommands onto the library.hdm-am-app— a native Slint GUI application (binaryhdm-app) with desktop entrypoint now and mobile packaging scaffolds for Android/iOS.hdm-am-bridge— a localhost HTTP server (binaryhdm-bridge) that exposes the protocol to a browser over CORS, since a browser cannot open a raw TCP socket. See HTTP bridge.
Scope
The crate speaks the HDM TCP protocol directly:
- 12-byte fixed header with
D5 80 D4 B4 D5 84magic ("ՀԴՄ" in UTF-8), 2-byte protocol version, 1-byte operation code, 2-byte big-endian payload length. - 3DES-ECB-PKCS7-encrypted JSON payloads.
- Two-key model: a SHA-256-derived password key for operator login (and the operator/department listing), a session key returned by login for everything after.
- All 16 operations from spec v0.7.3.
It does not handle the surrounding business logic — selecting an HDM device, persisting fiscal receipts, deciding what to print. That belongs to the consumer.
Operation coverage
All 16 operations are implemented and unit-tested. Hardware behaviour is recorded per device and firmware below — the protocol is the same across terminals, but what a given firmware actually does (logo rendering, returns, eMark validation) varies, so the status column is firmware-specific.
Tested devices
| Device | OS / build | Firmware | HDM protocol / software |
|---|---|---|---|
| Newland N950 | Android 6 / Android 12 (SKQ1.220119.001) |
D_03_51_00_01010000 |
0.7 / 1.1.0 |
The device reports protocol version 0.7 (matching spec v0.7.x) in its responses, yet accepts the 0.5-framed requests this crate sends — so hdm probe keys identity on the protocol major version, not an exact match.
Per-operation status
Each row is keyed by the protocol operation code (the byte sent on the wire, 1–16) and its function name.
| Code | Function | Newland N950 (D_03_51_00_01010000) |
|---|---|---|
| 1 | List operators & departments | OK |
| 2 | Operator login | OK |
| 3 | Operator logout | OK |
| 4 | Print receipt | OK — registered a real fiscal sale |
| 5 | Reprint last receipt | OK |
| 6 | Get returnable receipt (lookup) | Blocked — returns 503 for any input [1] |
| 7 | Header / footer config | OK |
| 8 | Header logo | Accepted (200) but not rendered [2] |
| 9 | Fiscal report (X / Z) | OK — both; a Z-report does not lock the device [3] |
| 10 | Print return | Blocked — returns 174 for any input [1] |
| 11 | Cash in / out | OK — both directions |
| 12 | Date / time | OK |
| 13 | Receipt sample | OK |
| 14 | Time sync | OK |
| 15 | Payment systems list | OK |
| 16 | Single eMark | Error path only [4] |
Notes:
- Returns (get returnable receipt and print return). Both are wire-correct but rejected server-side on this firmware. They key on a
Receipt_IDthat lives only in the receipt-print response'sqrfield — and this firmware omitsqrentirely (confirmed on the raw decrypted payload, not justnull). So the lookup returns vendor code503and the return returns174("receipt to return does not exist") for every identifier tried (rseq, fiscal number, zero-padded).crnis correct (a wrongcrngives175). A realReceipt_IDmust come out-of-band. The lookup response shape (ReturnableReceiptResponse) is therefore modelled from the spec alone and is unverified — see its doc comment. - Header logo. The protocol accepts a Base64 BMP and returns success, but no custom logo prints (tried 1-bit BMP/PNG at 384×4 and 384×64). The firmware appears to ignore custom header logos.
- Z-report. Verified that a Z-report closes the fiscal shift without locking the device — the next receipt opens a new fiscal day.
- Single eMark. Only the error path is verified: malformed codes return
195. The success path needs a real, registered GS1 Data Matrix code from a marked product.
Behaviour on other models or firmware versions is unknown — additions to this table are welcome.
Library usage
use TcpStream;
use Duration;
use ;
let tcp = connect?;
tcp.set_read_timeout?;
let mut client = new;
client.login?; // cashier id + PIN
let receipt = client.print_receipt?;
println!;
client.logout?;
All monetary and quantity fields use rust_decimal::Decimal (re-exported as hdm_am::Decimal); the wire encoding stays a JSON number.
CLI usage
Connection parameters come from flags or the HDM_* environment variables. The CLI exposes all 16
protocol operations; run hdm --help or hdm <command> --help for the full argument surface.
receipt --items expects a JSON array of ReceiptItem objects. return --return-items expects a
JSON array like [{"rpid":100,"quantity":1}], using the item row IDs returned by
lookup-receipt.
Irreversible operations (receipt, return, cash, Z-report) prompt for confirmation unless --yes is passed. -v/-vv raise log verbosity (logs go to stderr; -vv traces the raw decrypted payloads).
GUI app
The native GUI lives in app/ and uses Slint without a webview. It has buttons for all 16
protocol operations plus the unauthenticated Probe. HDM calls run on a worker thread so the UI
event loop is not blocked by the protocol's long response timeout.
The crate is both a desktop binary and a library:
app/src/main.rs— desktop entrypoint (hdm-app).app/src/lib.rs— shared app runner plus Androidandroid_mainhook and iOS backend selection.app/ui/main.slint— compiled Slint UI markup.app/src/bridge.rs— UI callbacks, validation, TCP connection setup, and background HDM calls.app/ios/— XcodeGen project template that delegates the iOS executable build to Cargo.
Current platform status:
- Desktop macOS/Windows/Linux: directly runnable with
cargo run -p hdm-am-app. - Android: scaffolded with
cdylib,android_main,cargo-apkmetadata, and TCP network permissions. - iOS: scaffolded with an XcodeGen project, Cargo build script, Winit + Skia backend selection, and Local Network privacy text.
See app/README.md for Android/iOS toolchain setup and build commands.
The first GUI iteration deliberately keeps structured payload editing simple: receipt items,
return-item lists, and header/footer config are loaded from JSON file paths using the same shapes as
the CLI/library types; logo upload reads a BMP path and Base64-encodes it before sending. Operations
that print, submit data, configure the device, or otherwise change state require the Confirm side effect checkbox before dispatch.
Before dispatching a request, the GUI validates connection settings and operation-specific fields: numeric ranges, money precision, required department/cashier/PIN fields, CRN/TIN/RRN/terminal ID formats, eMark length/character rules, report ranges, receipt/return item JSON, header/footer text limits, and BMP logo depth. Device responses are formatted as task-oriented summaries; HDM error codes are shown with their meaning and a suggested recovery action.
The GUI includes a Demo mode for store review, training, and first-run checks without fiscal
hardware. Demo mode returns synthetic responses for every operation and sends no network traffic.
Privacy policy and store-readiness notes live in PRIVACY.md and
docs/store-compliance.md.
Slint is pinned to =1.13.1 because it is the latest checked version whose rust-version matches
this workspace's MSRV (1.85). Newer Slint releases currently require Rust 1.88+. Slint's runtime is
licensed separately (GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0), so binary distribution of the GUI must account for Slint's license
terms.
HTTP bridge
A browser can speak HTTP/WebSocket but not raw TCP, while the HDM protocol is raw 3DES-over-TCP. The
bridge/ crate closes that gap: hdm-bridge is a small localhost HTTP server that takes
JSON on one side and runs the HDM TCP protocol (via hdm_am::Client) on the other — one
POST /v1/<op> per operation. The server logic is exposed as hdm_am_bridge::serve so it can also be
embedded in another process (e.g. the GUI app).
HDM_BRIDGE_TOKEN= \
HDM_BRIDGE_ALLOW_ORIGIN=https://your-web-app.example \
HDM_HOST=10.0.0.5 HDM_PASSWORD=… HDM_CASHIER=3 HDM_PIN=1234 \
The hdm CLI can also supervise it as a background process (Unix), so you don't have to keep a
terminal open. It runs the hdm-bridge binary as a child — the CLI takes on no dependency on the
bridge crate; device connection comes from the usual global --host/--password/--cashier/--pin
flags (or HDM_* env):
Every operation is a POST with a uniform envelope: an optional per-request connection override
(merged field-by-field over the configured default device) and the operation's params (the library
request type verbatim — PrintReceiptRequest, FiscalReportRequest, …):
// POST /v1/receipt
{
"connection": { "host": "10.0.0.5", "cashier": 3 }, // optional; falls back to the configured default
"params": { "mode": 1, "paidAmount": 1000.0, "paidAmountCard": 0, "partialAmount": 0,
"prePaymentAmount": 0, "useExtPOS": false, "dep": 1 }
}
Routes mirror the CLI: /v1/probe, /v1/operators, /v1/login, /v1/receipt, /v1/receipt/last,
/v1/receipt/lookup, /v1/return, /v1/report, /v1/cash, /v1/datetime, /v1/time-sync,
/v1/payment-systems, /v1/emark, /v1/sample, /v1/header-footer, /v1/logo, plus /v1/health
(public liveness) and /v1/info. Errors render as a stable envelope carrying the device error code
and the library's recovery hints:
{ "error": { "kind": "device_error", "code": 174, "message": "…",
"retryable": false, "requires_relogin": false, "requires_reconnect": true } }
Configuration comes from flags or HDM_* / HDM_BRIDGE_* environment variables (--help lists them).
Because a per-request connection can target any host, the bridge is a security boundary: it binds
loopback only, requires Authorization: Bearer <HDM_BRIDGE_TOKEN> on every route except /v1/health
(refusing to start without a token unless --insecure-no-auth is passed), restricts callers to an
explicit --allow-origin allow-list, and serializes device access to one session at a time.
Calling it from an HTTPS page. http://127.0.0.1 is a "potentially trustworthy" origin, so mixed
content is not the obstacle. The bridge answers Chrome's Private Network Access preflight
(Access-Control-Allow-Private-Network: true), but recent Chrome additionally prompts the user to
allow a connection to a local device. For a frictionless production deployment, terminate TLS on a
loopback domain (a real certificate for a name that resolves to 127.0.0.1) so the page talks
https to https; that path is a planned follow-up, not yet shipped.
OpenAPI document
The bridge describes its own HTTP surface as an OpenAPI 3.1 document, assembled from the same
schemars-derived schemas the handlers serialize — so the contract cannot drift from the code. It
is committed at docs/openapi.json (regenerated by
cargo run -p hdm-am-bridge --example dump-openapi --features schema, CI-checked with --check),
served at GET /v1/openapi.json, and rendered as a live API explorer at GET /docs. Any generator
can consume it off a running bridge:
TypeScript clients & web demo
The clients/ pnpm workspace builds three packages on top of that document:
@hdm-am/client— an isomorphic, zero-dependency TS client (one typed method per operation, typed errors), with types generated fromdocs/openapi.json.@hdm-am/react— a provider and typed hooks over the client (reactis the only peer dep).demo— a Vite + React + shadcn/ui app that drives a real device from the browser.
See clients/README.md for the full pipeline and how to run the demo.
Source spec
The State Revenue Committee of Armenia publishes the integration manual as a PDF on src.am. This crate targets v0.7.3 (2025-04, 34 pages). The original and an unofficial English translation are checked in for offline reference:
docs/history/hdm-protocol-v0.7.3-2025.pdf— original Armenian spec from src.am, authoritative. It is the newest entry in the version archive below.docs/spec.md— English translation (unofficial; for developer convenience). Where the two disagree, trust the PDF — translator's notes inspec.mdflag the corrections.docs/history/— every published revision from v0.3 (2015) to v0.7.3, archived offline with a per-version index and a wire-protocol changelog. The transport envelope (framing, 3DES-ECB, SHA-256 keys) has been stable since v0.3; the header version byte has been05since v0.5.
Machine-readable schema
JSON Schema for every request/response payload lives in docs/schema/ — one file
per type, generated from the Rust types behind the schema feature so they can't drift from the
code. They cover the decrypted JSON bodies (not the binary framing / 3DES envelope); money fields are
JSON numbers and integer-coded enums are integers, matching the wire.
Design
Client<T: Read + Write, S: SequenceProvider>is generic over its transport and its sequence-number provider — pass aTcpStream+InMemorySeq/FileSeqin production, or any mock in tests.- Synchronous API. Consumers needing async should wrap calls in
tokio::task::spawn_blockingor similar. - No global state. Each
Clientowns its session key and sequence counter. - Sequence-counter persistence is the consumer's choice (
InMemorySeq,FileSeq, or a customSequenceProvider).
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.