{"id":50765138,"url":"https://github.com/lobotomoe/hdm-am","last_synced_at":"2026-06-11T13:01:43.062Z","repository":{"id":362577957,"uuid":"1259544546","full_name":"lobotomoe/hdm-am","owner":"lobotomoe","description":"Rust client + CLI for the Armenian fiscal cash register (HDM) protocol — State Revenue Committee integration spec v0.7.3","archived":false,"fork":false,"pushed_at":"2026-06-05T00:03:22.000Z","size":6723,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-05T00:05:23.681Z","etag":null,"topics":["3des","armenia","cli","fiscal","hdm","point-of-sale","rust","tcp"],"latest_commit_sha":null,"homepage":null,"language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/lobotomoe.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE-APACHE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-04T16:01:39.000Z","updated_at":"2026-06-05T00:03:26.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/lobotomoe/hdm-am","commit_stats":null,"previous_names":["lobotomoe/hdm-am"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/lobotomoe/hdm-am","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lobotomoe%2Fhdm-am","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lobotomoe%2Fhdm-am/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lobotomoe%2Fhdm-am/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lobotomoe%2Fhdm-am/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/lobotomoe","download_url":"https://codeload.github.com/lobotomoe/hdm-am/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/lobotomoe%2Fhdm-am/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34199516,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-11T02:00:06.485Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["3des","armenia","cli","fiscal","hdm","point-of-sale","rust","tcp"],"created_at":"2026-06-11T13:01:41.257Z","updated_at":"2026-06-11T13:01:43.048Z","avatar_url":"https://github.com/lobotomoe.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# hdm-am\n\nA 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**, ՀԴՄ).\n\nThe workspace contains four crates:\n\n- **`hdm-am`** — the library: wire framing, encryption, and one typed request/response per operation.\n- **`hdm-am-cli`** — a thin command-line tool (binary `hdm`) that maps subcommands onto the library.\n- **`hdm-am-app`** — a native Slint GUI application (binary `hdm-app`) with desktop entrypoint now\n  and mobile packaging scaffolds for Android/iOS.\n- **`hdm-am-bridge`** — a localhost HTTP server (binary `hdm-bridge`) that exposes the protocol to a\n  browser over CORS, since a browser cannot open a raw TCP socket. See [HTTP bridge](#http-bridge).\n\n## Scope\n\nThe crate speaks the HDM TCP protocol directly:\n\n- 12-byte fixed header with `D5 80 D4 B4 D5 84` magic (\"ՀԴՄ\" in UTF-8), 2-byte protocol version, 1-byte operation code, 2-byte big-endian payload length.\n- 3DES-ECB-PKCS7-encrypted JSON payloads.\n- 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.\n- All 16 operations from spec v0.7.3.\n\nIt does **not** handle the surrounding business logic — selecting an HDM device, persisting fiscal receipts, deciding what to print. That belongs to the consumer.\n\n## Operation coverage\n\nAll 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.\n\n### Tested devices\n\n| Device | OS / build | Firmware | HDM protocol / software |\n|---|---|---|---|\n| Newland N950 | Android 6 / Android 12 (`SKQ1.220119.001`) | `D_03_51_00_01010000` | `0.7` / `1.1.0` |\n\nThe 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.\n\n### Per-operation status\n\nEach row is keyed by the protocol operation code (the byte sent on the wire, 1–16) and its function name.\n\n| Code | Function | Newland N950 (`D_03_51_00_01010000`) |\n|---:|---|---|\n| 1 | List operators \u0026 departments | OK |\n| 2 | Operator login | OK |\n| 3 | Operator logout | OK |\n| 4 | Print receipt | OK — registered a real fiscal sale |\n| 5 | Reprint last receipt | OK |\n| 6 | Get returnable receipt (lookup) | Blocked — returns `503` for any input [1] |\n| 7 | Header / footer config | OK |\n| 8 | Header logo | Accepted (`200`) but not rendered [2] |\n| 9 | Fiscal report (X / Z) | OK — both; a Z-report does not lock the device [3] |\n| 10 | Print return | Blocked — returns `174` for any input [1] |\n| 11 | Cash in / out | OK — both directions |\n| 12 | Date / time | OK |\n| 13 | Receipt sample | OK |\n| 14 | Time sync | OK |\n| 15 | Payment systems list | OK |\n| 16 | Single eMark | Error path only [4] |\n\n**Notes:**\n\n1. **Returns** (*get returnable receipt* and *print return*). Both are wire-correct but rejected server-side on this firmware. They key on a `Receipt_ID` that lives only in the receipt-print response's `qr` field — and this firmware omits `qr` entirely (confirmed on the raw decrypted payload, not just `null`). So the lookup returns vendor code `503` and the return returns `174` (\"receipt to return does not exist\") for every identifier tried (`rseq`, fiscal number, zero-padded). `crn` is correct (a wrong `crn` gives `175`). A real `Receipt_ID` must come out-of-band. The lookup response shape (`ReturnableReceiptResponse`) is therefore modelled from the spec alone and is **unverified** — see its doc comment.\n2. **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.\n3. **Z-report.** Verified that a Z-report closes the fiscal shift without locking the device — the next receipt opens a new fiscal day.\n4. **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.\n\nBehaviour on other models or firmware versions is unknown — additions to this table are welcome.\n\n## Library usage\n\n```rust\nuse std::net::TcpStream;\nuse std::time::Duration;\nuse hdm_am::{Client, InMemorySeq, PrintReceiptRequest, PrintMode, Decimal};\n\nlet tcp = TcpStream::connect(\"10.0.0.5:1025\")?;\ntcp.set_read_timeout(Some(Duration::from_secs(50)))?;\nlet mut client = Client::new(tcp, \"\u003chdm-password\u003e\", InMemorySeq::default());\n\nclient.login(3, \"1234\")?;                 // cashier id + PIN\nlet receipt = client.print_receipt(PrintReceiptRequest {\n    mode: PrintMode::Simple,\n    paid_amount: Decimal::new(1000, 2),   // 10.00 cash\n    paid_amount_card: Decimal::ZERO,\n    partial_amount: Decimal::ZERO,\n    pre_payment_amount: Decimal::ZERO,\n    dep: Some(1),\n    partner_tin: None,\n    use_ext_pos: false,\n    payment_system: None,\n    rrn: None,\n    terminal_id: None,\n    e_marks: vec![],\n    items: vec![],\n})?;\nprintln!(\"fiscal #{} (seq {})\", receipt.fiscal, receipt.rseq);\nclient.logout()?;\n```\n\nAll monetary and quantity fields use `rust_decimal::Decimal` (re-exported as `hdm_am::Decimal`); the wire encoding stays a JSON number.\n\n## CLI usage\n\nConnection parameters come from flags or the `HDM_*` environment variables. The CLI exposes all 16\nprotocol operations; run `hdm --help` or `hdm \u003ccommand\u003e --help` for the full argument surface.\n\n```sh\nexport HDM_HOST=10.0.0.5 HDM_PORT=1025 HDM_PASSWORD=\u003chdm-password\u003e HDM_CASHIER=3 HDM_PIN=1234\n\nhdm probe                                            # confirm the endpoint is an HDM (no login)\nhdm operators                                        # list operators \u0026 departments (no login)\nhdm receipt --mode simple --cash 10 --dep 1          # print a fiscal receipt (prompts first)\nhdm receipt --mode products --card 10 --items items.json --use-ext-pos --rrn 123456789012 --terminal-id 12345678 --emark \u003ccode\u003e\nhdm report --kind x                                  # interim X-report\nhdm report --kind x --transaction-type 1             # X-report filtered by transaction type\nhdm lookup-receipt --receipt-id 123 --crn 51815332   # read-only receipt lookup\nhdm return --crn 51815332 --ticket 123 --return-items return-items.json --emark \u003ccode\u003e\nhdm --json datetime                                  # machine-readable output to stdout\n```\n\n`receipt --items` expects a JSON array of `ReceiptItem` objects. `return --return-items` expects a\nJSON array like `[{\"rpid\":100,\"quantity\":1}]`, using the item row IDs returned by\n`lookup-receipt`.\n\nIrreversible 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).\n\n## GUI app\n\nThe native GUI lives in [`app/`](app/) and uses Slint without a webview. It has buttons for all 16\nprotocol operations plus the unauthenticated `Probe`. HDM calls run on a worker thread so the UI\nevent loop is not blocked by the protocol's long response timeout.\n\n```sh\ncargo run -p hdm-am-app\n```\n\nThe crate is both a desktop binary and a library:\n\n- `app/src/main.rs` — desktop entrypoint (`hdm-app`).\n- `app/src/lib.rs` — shared app runner plus Android `android_main` hook and iOS backend selection.\n- `app/ui/main.slint` — compiled Slint UI markup.\n- `app/src/bridge.rs` — UI callbacks, validation, TCP connection setup, and background HDM calls.\n- `app/ios/` — XcodeGen project template that delegates the iOS executable build to Cargo.\n\nCurrent platform status:\n\n- Desktop macOS/Windows/Linux: directly runnable with `cargo run -p hdm-am-app`.\n- Android: scaffolded with `cdylib`, `android_main`, `cargo-apk` metadata, and TCP network\n  permissions.\n- iOS: scaffolded with an XcodeGen project, Cargo build script, Winit + Skia backend selection, and\n  Local Network privacy text.\n\nSee [`app/README.md`](app/README.md) for Android/iOS toolchain setup and build commands.\n\nThe first GUI iteration deliberately keeps structured payload editing simple: receipt items,\nreturn-item lists, and header/footer config are loaded from JSON file paths using the same shapes as\nthe CLI/library types; logo upload reads a BMP path and Base64-encodes it before sending. Operations\nthat print, submit data, configure the device, or otherwise change state require the `Confirm side\neffect` checkbox before dispatch.\n\nBefore dispatching a request, the GUI validates connection settings and operation-specific fields:\nnumeric ranges, money precision, required department/cashier/PIN fields, CRN/TIN/RRN/terminal ID\nformats, eMark length/character rules, report ranges, receipt/return item JSON, header/footer text\nlimits, and BMP logo depth. Device responses are formatted as task-oriented summaries; HDM error\ncodes are shown with their meaning and a suggested recovery action.\n\nThe GUI includes a Demo mode for store review, training, and first-run checks without fiscal\nhardware. Demo mode returns synthetic responses for every operation and sends no network traffic.\nPrivacy policy and store-readiness notes live in [`PRIVACY.md`](PRIVACY.md) and\n[`docs/store-compliance.md`](docs/store-compliance.md).\n\nSlint is pinned to `=1.13.1` because it is the latest checked version whose `rust-version` matches\nthis workspace's MSRV (`1.85`). Newer Slint releases currently require Rust 1.88+. Slint's runtime is\nlicensed separately (`GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR\nLicenseRef-Slint-Software-3.0`), so binary distribution of the GUI must account for Slint's license\nterms.\n\n## HTTP bridge\n\nA browser can speak HTTP/WebSocket but not raw TCP, while the HDM protocol is raw 3DES-over-TCP. The\n[`bridge/`](bridge/) crate closes that gap: `hdm-bridge` is a small localhost HTTP server that takes\nJSON on one side and runs the HDM TCP protocol (via `hdm_am::Client`) on the other — one\n`POST /v1/\u003cop\u003e` per operation. The server logic is exposed as `hdm_am_bridge::serve` so it can also be\nembedded in another process (e.g. the GUI app).\n\n```sh\nHDM_BRIDGE_TOKEN=$(openssl rand -hex 16) \\\nHDM_BRIDGE_ALLOW_ORIGIN=https://your-web-app.example \\\nHDM_HOST=10.0.0.5 HDM_PASSWORD=… HDM_CASHIER=3 HDM_PIN=1234 \\\n  cargo run -p hdm-am-bridge          # listens on 127.0.0.1:8077 by default\n```\n\nThe `hdm` CLI can also supervise it as a background process (Unix), so you don't have to keep a\nterminal open. It runs the `hdm-bridge` binary as a child — the CLI takes on no dependency on the\nbridge crate; device connection comes from the usual global `--host`/`--password`/`--cashier`/`--pin`\nflags (or `HDM_*` env):\n\n```sh\nhdm --host 10.0.0.5 --password … --cashier 3 --pin 1234 \\\n  bridge start --token \"$TOKEN\" --allow-origin https://your-web-app.example\nhdm bridge status     # running? pid / bind / uptime\nhdm bridge stop       # graceful SIGTERM\nhdm bridge run        # foreground instead (for a service manager: it execs the bridge)\n```\n\nEvery operation is a `POST` with a uniform envelope: an optional per-request `connection` override\n(merged field-by-field over the configured default device) and the operation's `params` (the library\nrequest type verbatim — `PrintReceiptRequest`, `FiscalReportRequest`, …):\n\n```jsonc\n// POST /v1/receipt\n{\n  \"connection\": { \"host\": \"10.0.0.5\", \"cashier\": 3 },  // optional; falls back to the configured default\n  \"params\": { \"mode\": 1, \"paidAmount\": 1000.0, \"paidAmountCard\": 0, \"partialAmount\": 0,\n              \"prePaymentAmount\": 0, \"useExtPOS\": false, \"dep\": 1 }\n}\n```\n\nRoutes mirror the CLI: `/v1/probe`, `/v1/operators`, `/v1/login`, `/v1/receipt`, `/v1/receipt/last`,\n`/v1/receipt/lookup`, `/v1/return`, `/v1/report`, `/v1/cash`, `/v1/datetime`, `/v1/time-sync`,\n`/v1/payment-systems`, `/v1/emark`, `/v1/sample`, `/v1/header-footer`, `/v1/logo`, plus `/v1/health`\n(public liveness) and `/v1/info`. Errors render as a stable envelope carrying the device error code\nand the library's recovery hints:\n\n```jsonc\n{ \"error\": { \"kind\": \"device_error\", \"code\": 174, \"message\": \"…\",\n             \"retryable\": false, \"requires_relogin\": false, \"requires_reconnect\": true } }\n```\n\nConfiguration comes from flags or `HDM_*` / `HDM_BRIDGE_*` environment variables (`--help` lists them).\nBecause a per-request `connection` can target any host, the bridge is a security boundary: it binds\nloopback only, requires `Authorization: Bearer \u003cHDM_BRIDGE_TOKEN\u003e` on every route except `/v1/health`\n(refusing to start without a token unless `--insecure-no-auth` is passed), restricts callers to an\nexplicit `--allow-origin` allow-list, and serializes device access to one session at a time.\n\n**Calling it from an HTTPS page.** `http://127.0.0.1` is a \"potentially trustworthy\" origin, so mixed\ncontent is not the obstacle. The bridge answers Chrome's Private Network Access preflight\n(`Access-Control-Allow-Private-Network: true`), but recent Chrome additionally prompts the user to\nallow a connection to a local device. For a frictionless production deployment, terminate TLS on a\nloopback domain (a real certificate for a name that resolves to `127.0.0.1`) so the page talks\n`https` to `https`; that path is a planned follow-up, not yet shipped.\n\n## Source spec\n\nThe 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:\n\n- [`docs/history/hdm-protocol-v0.7.3-2025.pdf`](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.\n- [`docs/spec.md`](docs/spec.md) — English translation (unofficial; for developer convenience). Where the two disagree, trust the PDF — translator's notes in `spec.md` flag the corrections.\n- [`docs/history/`](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 been `05` since v0.5.\n\n## Machine-readable schema\n\nJSON Schema for every request/response payload lives in [`docs/schema/`](docs/schema/) — one file\nper type, **generated from the Rust types** behind the `schema` feature so they can't drift from the\ncode. They cover the decrypted JSON bodies (not the binary framing / 3DES envelope); money fields are\nJSON numbers and integer-coded enums are integers, matching the wire.\n\n```sh\ncargo run --example dump-schema --features schema             # (re)generate docs/schema/*.json\ncargo run --example dump-schema --features schema -- --check  # CI guard: fail if stale\n```\n\n## Design\n\n- `Client\u003cT: Read + Write, S: SequenceProvider\u003e` is generic over its transport and its sequence-number provider — pass a `TcpStream` + `InMemorySeq`/`FileSeq` in production, or any mock in tests.\n- Synchronous API. Consumers needing async should wrap calls in `tokio::task::spawn_blocking` or similar.\n- No global state. Each `Client` owns its session key and sequence counter.\n- Sequence-counter persistence is the consumer's choice (`InMemorySeq`, `FileSeq`, or a custom `SequenceProvider`).\n\n## License\n\nLicensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or [MIT license](LICENSE-MIT) at your option.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flobotomoe%2Fhdm-am","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Flobotomoe%2Fhdm-am","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flobotomoe%2Fhdm-am/lists"}