HERMES · emergency communications · system design whitepaper
A complete design for a small appliance that takes an ARRL radiogram, an ICS form, or a short message from a web browser and delivers it to another station: over the internet when there is one, over VHF packet where there is a path, and over HF with JS8Call when there is nothing else. Every node is also an unattended propagation and reachability instrument, which is the part that makes it worth running for the two years before it is needed. Hardware, enclosure, software, wire protocol, regulatory posture, forms and reporting, governance, and the arithmetic behind all of it. Published for review before anything is built.
| document | HERMES, Ham Emergency Relay and Message Exchange System |
| version | 2.2, revised against a reference implementation |
| date | 12 August 2026 |
| author | Mark, K9MEV · markvd.net |
| hardware | Raspberry Pi 5 · QRP Labs QMX 80/60/40/30/20 m |
| transports | HTTPS over IP · AX.25 on VHF · JS8 on HF, in that order |
| status | implemented and tested; never yet on the air |
| license | MIT, see section 20 |
Draft for public review. This document describes a system that has not been built. Every performance figure marked as an estimate needs measurement, and the regulatory reading in section 3 is the author's own and is awaiting a second opinion from an ARRL Volunteer Counsel. Comments, corrections, and disagreement are all welcome, and section 20 lists the parts most worth attacking.
HERMES is a small, self-contained appliance that accepts short text messages (an ARRL radiogram, an ICS-213, or a short email) from a local web browser and delivers them to another HERMES station, using the internet when it is available and amateur radio when it is not. Each unit is a Raspberry Pi paired with a QRP Labs QMX five-band QRP transceiver in a single 3D printed enclosure. There are no local applications to install and no operator skill required beyond typing a message and pressing send: the unit decides how to move the traffic.
The design rests on five ideas.
Transport indifference. A message is a signed, immutable object with a content-addressed identifier. Nothing in the message format knows or cares whether it crossed fiber, Wi-Fi, or 40 meters. A single message may traverse both in one hop each. This is what makes the internet-preferred, RF-fallback behavior tractable rather than a special case tangle.
Measurement before payload. Every station continuously advertises that it exists and continuously records who it hears, who hears it, and what the noise floor was at the time. The result is a measured reachability and propagation record: which stations, on which bands, at what hours, at what signal to noise ratio, and whether they also answer over IP. Routing decisions come from measured history rather than from prediction.
This deserves more emphasis than a design document normally gives to a database, so it has section 12 to itself. A messaging network is worth nothing until it has peers. A measurement network is worth something on the first day, with one node. Every unit is an unattended, calibrated, continuously operating HF path measurement instrument that happens to also pass traffic, and that property is what makes it rational to run one for the two years before the emergency that justifies it. The messaging capability is what the system is for; the measurement capability is why anyone would keep it running long enough for the messaging to matter.
Minimal, replaceable central infrastructure. The internet path uses a small rendezvous and mailbox service (the "hub"). The hub holds no long-term message content it does not need, is trivially self-hostable, and is federated. If every hub disappears, stations that have previously met each other still exchange traffic directly over IP or over RF. The hub is an optimization, not a dependency.
Verifiable delivery. Every message carries an Ed25519 signature from its originator. Every delivery produces a receipt signed by the destination and countersigned along the path. Over RF, where a 512 bit signature is prohibitively expensive, HERMES carries a 40 bit digest and issues a compact receipt that is automatically upgraded to a full cryptographic receipt the moment either party regains internet connectivity. Nothing in the message content is ever obscured, which keeps the design inside 47 CFR 97.113(a)(4).
Legal by construction. The RF behavior is designed against 47 CFR 97.221 rather than retrofitted to it. Unattended beaconing happens only inside the automatically controlled digital station segments. Automatic responses to interrogation elsewhere are held below 500 Hz occupied bandwidth by using JS8 Normal or Slow speed exclusively when unattended. Station identification, third party traffic rules, and the 60 meter channel restrictions are handled explicitly in the software rather than left to the operator.
Estimated cost per node is 250 to 300 USD for the core electronics and enclosure, and 400 to 500 USD for a complete off-grid station including antenna, battery, and a small solar panel. Expected throughput is one 25 word radiogram in roughly 6 to 11 minutes end to end over HF at JS8 Normal speed, a few seconds over a VHF packet link, and effectively instantaneous over IP.
Between revision 1.2 and this one, the whole design was implemented. That is the most useful thing that can happen to a design document, because a specification only finds out where it was wrong when something has to obey it. Three findings were significant enough to change the text rather than sit in an errata list.
An RF transport cannot answer synchronously. The transport interface in 4.1
has three verbs, and offer was specified as though a delivery attempt either
succeeds or fails. Over IP that is true in a second; over HF an exchange takes
ten minutes. Without a way to say "under way, do not start another path", the
router will cheerfully put the same message on the air twice by two routes. The
interface now carries a pending result (see 4.1) and the router leaves such a
message alone until the transport reports back.
Compression cannot be a wire transform. Section 9.10 described abbreviation and ARL numbered text substitution as encoding steps. They are not, and cannot be: they change the message body, and the body is signed. Any transformation applied after signing breaks every signature over it. They are composition aids, applied before the message is created, and the transport's job is to refuse a body it cannot carry rather than quietly clean it up. This is corrected in 9.10.
The frame arithmetic in revision 1.2 understated the cost by about half. It charged one JS8 frame per protocol frame, but a 40 character fragment plus its header is around 55 characters, which is three JS8 frames at roughly 20 characters each. Section 15 now uses the corrected accounting, and its figures come from the real encoder and the real ARQ rather than from an estimate.
Three further deviations were made deliberately during implementation and are recorded in 22.1 rather than silently folded in, because a reader comparing the document to the code deserves to find the differences listed rather than discover them.
Honesty about prior art is cheaper before publication than after, and a reviewer who knows this landscape will otherwise stop reading at the first unearned claim of novelty.
| System | What it does | Why this design is not it |
|---|---|---|
| Winlink | The incumbent. Global gateway network, radio email, hybrid internet and RF, radio-only forwarding, and a forms library covering hundreds of agency documents | Winlink is a client an operator drives, not an unattended appliance, and its best modems are closed or expensive. HERMES aims to interoperate with it (11.6) rather than displace it. The forms library is Winlink's real moat and 11.5 exists because of it |
| JS8Call | Provides the RF layer used here: weak signal messaging, a store and forward inbox, relaying, and heartbeat networking | A large fraction of the RF capability described in section 9 is JS8Call's, not this project's. What is added is orchestration, transport selection, verifiable delivery, a workflow, and the measurement record. That is a real contribution and it is not a new radio protocol |
| VarAC | Popular HF messaging over the VARA modem, with store and forward | Windows centric and dependent on a closed modem. Same functional territory, different constraints |
| NBEMS, fldigi with flmsg | What many ARES groups actually use for forms, mostly on VHF | Entirely operator driven, no automation, no unattended operation, no measurement |
| Reticulum with LXMF | The closest architectural analogue: transport agnostic, cryptographic identity, signed objects, store and forward propagation nodes | Encrypted end to end, which makes it unusable on US amateur HF under 97.113(a)(4). The plain-content, publicly-documented-encoding posture of this design is the difference, and it is the reason this is not simply a Reticulum deployment |
| Meshtastic, MeshCore | Own the mindshare for off-grid text messaging, at LoRa ranges, without a license | Different range class. A useful fourth transport rather than a competitor, see 6.11 |
| Satellite, Starlink and Iridium | What agencies increasingly buy instead of partnering with amateur radio | The honest competitor. This design serves the tail case, the drill culture, and the measurement record, and it should not pretend otherwise |
The defensible claims are narrow and worth stating precisely: an unattended appliance rather than an operator-driven client, automatic transport selection across IP, VHF, and HF, delivery verification without encryption, a Part 97 posture engine enforced below the user interface, and a continuous measurement record as a first-class product.
The following table maps each stated requirement to the section that satisfies it. Any requirement that could not be satisfied cleanly is flagged and recorded in section 20 (decisions and open items).
| # | Requirement | Where addressed | Notes |
|---|---|---|---|
| R1 | Pass short text: email or ARRL radiogram | 11 | Radiogram is the native object; email and ICS-213 are profiles of it |
| R2 | Raspberry Pi based | 6.1, 6.2 | Pi 5 2 GB recommended, Pi 4 and Pi Zero 2 W variants specified |
| R3 | QRP Labs QMX transceiver | 6.3 | 80/60/40/30/20 m build recommended |
| R4 | Inexpensive multi-band antenna, no tuning | 6.5 | 40 m end fed half wave with 49:1 transformer, optional 80 m link |
| R5 | Single attractive 3D printable case | 7 | Two piece shell, swappable call sign plate, parametric source |
| R6 | Web interface for setup, all configuration, message control | 8.4, 8.5, appendix C | Local AP fallback, PWA, no cloud dependency |
| R7 | Call sign identity with /N or -N instance suffix | 5 | Canonical form CALLSIGN-N over IP, CALLSIGN/N over RF, with cost analysis |
| R8 | Dual mode, internet primary with limited central infrastructure | 4, 10 | Federated hub, direct peer paths, hub optional after first contact |
| R9 | RF fallback over JS8Call | 9.4 | JS8Call TCP API, JS8 Normal and Slow only when unattended |
| R10 | Idle beaconing and listening | 9.5 | Native JS8 heartbeat plus a HERMES capability advertisement |
| R11 | Database of stations heard, band, time, internet reachability | 8.3, appendix A | Observations, links, and reachability tables with hourly aggregation |
| R12 | Store and forward, intermediate stations | 9.7 | Explicit custody transfer with per hop receipts |
| R13 | Verifiable delivery | 9.8 | Signed end to end receipts, RF digest receipts upgraded later |
| R14 | Prefer internet over RF | 9.6 | Deterministic path scoring with internet weighted decisively |
| R15 | Receive-only SDR as a supported addition | 6.9 | Removes the receive side of the band scheduling compromise |
| R16 | Alternative radios supported | 6.4 | QMX+ and QDX and QDX-M through a driver abstraction |
| R17 | Installable on a stock Raspberry Pi OS image | 8.7 | Signed APT repository, three commands, no custom image required |
| R18 | Patchable and updatable, optionally automatic, on a read-only filesystem | 8.8 | Transactional update with rollback, offline bundles, policy interlocks |
| R19 | Internet-facing components hardened against misuse | 13 | Full attack surface inventory and hardening baseline |
| R20 | Served agency forms, and interoperation with Winlink | 11.5, 11.6 | Signed template engine, ICS set shipped, !WL bridge service |
| R21 | Communications log for after-action reporting | 11.7 | ICS-309 and ICS-214 generated from data already held |
| R22 | Net control view of the whole net | 8.9 | Traffic board and station board, visibility only, no command authority |
| R23 | VHF and UHF last mile | 6.11 | AX.25 over Direwolf as a third transport, preferred over HF |
| R24 | Delivery to people who do not have a node | 11.8 | Operator-asserted final delivery, service messages back to the originator |
| R25 | Fleet provisioning and configuration for a district | 8.10 | Pull-based signed policy bundles, narrowly scoped, local override wins |
| R26 | Propagation and reachability measurement as a product | 12 | Five collection streams, watched paths, aggregation, export |
| R27 | Project governance and continuity | 21.1 | Spec and implementation split, key custody, succession, conformance suite |
| R28 | Identity survives a failed card, and can be rotated | 5.3 | Encrypted backup, rotation certificates signed by the old key |
| R29 | Clock failure is visible before it is fatal | 6.7 | Offset sampled and surfaced, warn at 0.5 s, fail at 1.5 s |
| R30 | Battery state is measured, not assumed | 6.10 | Optional INA219; the update interlock reads it or treats it as unknown |
| R31 | The operator is told when a message will reach a person | 11.2, 11.7 | Estimated from this node's history, reported afterwards from what happened |
| R32 | A message cannot monopolize the channel | 11.2 | Airtime ceiling at composition, RF only, with shortening advice |
| R33 | Nobody is telephoned twice about the same message | 11.8 | Delivery records checked before recording another |
| R34 | Group name collisions stay courteous | 9.12 | Protocol frames held until a member is heard, with a recorded override |
| R35 | Duplicate station identifiers are found and both sides told | 13.5 | Conflict record, dashboard banner, priority message to the identifier |
| R36 | Exercises are run and measured | 14.4 | Real traffic, marked, per-participant report with findings in words |
| R37 | A hub can be operated and federated | 10.5 | Authenticated operator endpoints; push federation that cannot rewrite a key |
This section is the author's reading of the rules as they stand in August 2026 and is not legal advice. It should be reviewed before deployment, and the software is written so that every one of these constraints is a configuration value rather than a hard coded assumption, because rules change.
The controlling text is short enough to quote in substance. Section 97.221(b) permits a station to be automatically controlled while transmitting a data emission on the 6 m and shorter bands, and on the 28.120-28.189, 24.925-24.930, 21.090-21.100, 18.105-18.110, 14.0950-14.0995, 14.1005-14.112, 10.140-10.150, 7.100-7.105, and 3.585-3.600 MHz segments. Section 97.221(c) permits automatic control on any other frequency authorized for the emission type, except the 60 m channels specified in 97.303(h), provided that both conditions hold: the station is responding to interrogation by a station under local or remote control, and no transmission from the automatically controlled station occupies a bandwidth of more than 500 Hz.
HERMES turns this into two operating postures, and the software will not let a node transmit unattended outside them.
Posture A, unattended beacon and general availability. All unsolicited transmissions (heartbeats, capability advertisements, unsolicited relay attempts, queue drain to a station that has not just interrogated us) occur only on the 97.221(b) segments. The default channel plan in section 9.3 places every automatic channel inside those segments with margin.
Posture B, unattended response. Anywhere else, including the conventional JS8 calling frequencies, the node may transmit only in direct response to an interrogation it has just received, and only at JS8 Slow (25 Hz) or Normal (50 Hz) speed, both of which are comfortably under the 500 Hz limit. JS8 Fast (80 Hz) and JS8 40 or JS8 60 remain under 500 Hz as well, but they are disallowed unattended by policy because they degrade sensitivity and because the heartbeat subsystem does not support them.
Posture C, attended. With a control operator present at the unit (asserted through the web interface with a timeout, described in 8.4), the node operates as any other JS8Call station on the conventional watering holes, at any speed.
No message content, header, or address in HERMES is ever encrypted, compressed with a private dictionary, or otherwise rendered unreadable to a third party monitoring the channel. Everything transmitted over RF is either plain uppercase text or a documented, publicly specified encoding of plain text.
Three features that could be mistaken for encryption deserve explicit treatment.
Digital signatures. An Ed25519 signature appended to a plainly readable message does not obscure the meaning of that message. It authenticates the origin. This is the same posture taken by digital voice systems that transmit authenticated but unencrypted frames. Over RF, HERMES carries only a truncated digest, not a signature, for bandwidth reasons.
Compression. HERMES uses only publicly specified, static transformations over RF: the base 32 encoding in appendix B, the ARL numbered radiogram texts, and a published abbreviation table. There is no adaptive or private dictionary. The published table is part of the protocol document and is shipped with the firmware, so any monitor can decode the transmission with public information alone.
Message authentication codes. An earlier draft used a truncated HMAC with a pairwise shared key for RF authentication. That is dropped. A shared secret key on the air invites an argument that the transmission is codified, and the operational benefit is small. RF frames carry a CRC for corruption detection and a truncated public digest for binding to a signed object, both of which use published algorithms with no key material.
Station identification is emitted at least every 10 minutes during a transmission series and at the end of each series. JS8Call directed messages carry the originating call sign in the frame itself, which satisfies this in normal operation. HERMES adds a belt and braces timer: if 9 minutes elapse in a transmitting session without the base call sign having gone out in clear, the node inserts an identification frame. The identification always uses the base call sign, not the instance suffix.
HERMES will routinely carry traffic originated by non-licensed third parties, which is the entire point of an emergency messaging system. Domestic third party traffic is permitted. International third party traffic is permitted only with countries having a third party agreement with the United States. The software therefore:
Emergency override. The control operator requirement for domestic third party traffic may be lifted by an active emergency declaration (3.7). While a declaration is in force, third party traffic is accepted, queued, and forwarded unattended. Every message carried under the override is stamped with the declaration identifier, and the message record, the transmission log, and the audit log all carry it, so that the after-action record shows exactly which traffic moved under which declaration and on whose authority.
The override is deliberately narrow:
The message composer includes a plain language acknowledgment for any message that a served agency submits, and the node logs the acknowledgment. This is a policy control, not a technical one, but it belongs in the record.
The 60 m band is channelized under 97.303(h) with an effective radiated power limit referenced to a dipole, and 97.221(c) explicitly excludes those channels from automatic control. HERMES therefore treats 60 m as attended only, never uses it for beaconing or automatic response, and requires a manual power and antenna gain declaration before it will enable the band. Given the QMX at 5 W into a wire, the ERP limit is not a practical constraint, but the automatic control exclusion is absolute.
In a declared emergency involving immediate safety of life or protection of property, a station may use any means of radiocommunication at its disposal. HERMES exposes an explicit, logged, operator-asserted "distress override" that relaxes the band plan and priority scheduling. It does not relax the prohibition on obscuring meaning, because nothing in HERMES obscures meaning in the first place. Every use of the override is written to the audit log with a timestamp, the asserting operator, and a free text justification.
+-------------------------------+
| HERMES NODE |
Browser <--HTTPS--> | web (UI + REST + WS) |
(LAN/AP) | core (message store, |
| routing, receipts) |
| netd (IP transport) <-----+---> hub / peers
| rfd (RF transport) <--+ |
| radiod (CAT, SWR, band) | |
+----------------------------+--+
|
JS8Call TCP API
|
USB audio + CAT
|
QMX ---> antenna
The node is organized into three planes that are deliberately kept apart.
The message plane holds signed message objects and their delivery state. It never initiates anything. It exposes an append-only store with a small state machine per message.
The transport plane consists of independent, symmetric transports that each present the same three verbs to the core: probe(peer) returns a reachability estimate, offer(message, peer) attempts delivery, and poll() surfaces inbound messages, receipts, and failures. Adding a transport later (a LoRa link, a Meshtastic bridge, sneakernet by USB stick) means implementing three functions and touching nothing in the core. That claim was tested when VHF was added: it cost exactly three functions.
offer may return "pending", and this is not a detail. An HTTP POST answers in a second; an HF exchange takes ten minutes. A transport that has accepted a message but not finished with it says so, the router leaves that message alone rather than trying another path behind its back, and the outcome arrives later through poll. Without this the same message goes on the air twice by two routes, which wastes the scarcest resource the system has and confuses every station listening.
The knowledge plane is the station database: who exists, what they can do, when and how well we have heard them, and what worked. Both transports feed it; the router reads it. It is the piece with the longest useful life, because it accumulates into a genuinely valuable regional record of who is reachable how, at what hour, on what band.
Five long-lived services under systemd, all local, all communicating over a Unix domain socket bus with a simple length prefixed JSON framing:
| Service | Language | Responsibility | Restart policy |
|---|---|---|---|
hermes-core |
Python 3.12 | Message store, routing, receipts, scheduler, audit | on-failure, 5 s |
hermes-web |
Python (FastAPI) + static PWA | UI, REST, WebSocket push, auth | on-failure, 5 s |
hermes-netd |
Python | IP transport, hub registration, peer sessions, NAT traversal | on-failure, 10 s |
hermes-rfd |
Python | JS8Call API client, framing, ARQ, beacon, neighbor tracking | on-failure, 10 s |
hermes-radiod |
Python | Radio CAT, band switching, SWR qualification, PTT interlock | on-failure, 10 s |
hermes-scand |
Python | Receive-only SDR channelizer and scan decoders, optional | on-failure, 30 s |
hermes-vhfd |
Python | VHF and UHF transport over AX.25 via Direwolf, optional | on-failure, 10 s |
Python is chosen over Go or Rust for one reason that outweighs the others: the amateur community can read and modify it, and the existing JS8Call, Hamlib, and ADIF ecosystems are already there. The performance envelope is trivial (a few hundred messages per day, one 15 second radio frame at a time). Go remains a reasonable rewrite target for hermes-rfd if timing jitter ever becomes a problem, which is unlikely given that JS8Call itself is doing the hard real-time work.
A hardware watchdog (the Broadcom watchdog on the Pi, via systemd RuntimeWatchdogSec=30) covers total lockup. hermes-core additionally runs a liveness ping to each service every 15 seconds and restarts a service that misses three in a row.
The design assumes, in order of likelihood: internet loss (routine, expected, the entire reason for the system), power loss (expected, handled by battery and by a filesystem that survives abrupt power removal), JS8Call crash (observed in the wild, handled by supervision and reconnection), band closure (constant, handled by the scheduler), and node loss (handled by relaying and by the fact that any two stations that have met can find each other again).
Everything on the Pi that matters is in SQLite in WAL mode with synchronous=FULL on the message store, on an overlay filesystem with a read-only root. A yanked power cord loses at most the current in-flight radio frame.
A HERMES station identifier is a base call sign and an instance number:
K9MEV-3. The instance number is 0 to 15. Instance 0 is written without a suffix, so K9MEV and K9MEV-0 are the same station and the bare form is preferred.K9MEV/3. JS8Call handles a compound call sign suffix natively, at a cost described below.K9MEV-3 (Batavia EOC) where a free text label has been configured.The mapping between the two forms is mechanical and is performed at the transport boundary, so nothing above hermes-rfd ever sees a slash.
Decision: per-instance RF identity is a hard requirement. Every node transmits under its own compound call sign, K9MEV/1, and is directly addressable on the air as that station. A message addressed to instance 3 is delivered to instance 3 over RF, not to a shared front door that forwards internally. This section documents what that costs and how the design pays for it, because the cost is real.
The cost. JS8 packs a standard call sign into a fixed field inside the 69 bit frame payload. A compound call sign, meaning anything with a prefix or a suffix, does not fit that field, so JS8Call sends the call sign in an additional frame. At Normal speed that is an extra 15 seconds. On a 20 frame message exchange it is roughly a 5 percent penalty. On a single-frame heartbeat it is a 100 percent penalty, because one frame becomes two.
How the design pays for it.
The measured cost, now that the encoder exists. For a 25 word radiogram at JS8 Normal, a compound call sign adds roughly 12 percent to the total exchange rather than the 5 percent estimated in revision 1.2, because the extra frame is charged on each of the several transmission bursts an ARQ exchange involves rather than once. On a single-frame heartbeat it remains a 100 percent penalty. The halved beacon rates hold the aggregate spectrum occupancy unchanged; the message traffic penalty is real and is the price of the decision.
On VHF the penalty vanishes entirely. An AX.25 address field carries a call sign plus a 4 bit SSID, so K9MEV-1 is native and free. Every cost described in this section exists only on HF.
Co-site collision control. The most likely self-inflicted failure in this system is two of the operator's own nodes transmitting on the same channel at the same time, deafening each other and everyone nearby. The earlier draft solved this with a single RF transmit lease, which is no longer appropriate now that every instance has its own on-air identity. It is replaced by a co-site scheduler:
hermes-rfd monitors for its own other instances in the decode stream. Hearing a co-sited sibling at a very high SNR while attempting to transmit raises a co-site interference alarm in the user interface with the specific advice that matters, which is to separate the antennas or to put the nodes on different bands.An open verification item. JS8Call supports compound call signs, and the extra-frame behavior described above is the documented cost. What has not been verified by the author is the exact behavior of the heartbeat networking subsystem, the automatic SNR reply, and directed message addressing when the station call sign is compound, across both the mainline release and the Improved fork. This is the first on-air test in phase 0 and it is carried in the risk table, because per-instance RF identity is now a hard requirement rather than a preference, and a surprise here changes the design rather than an option in it.
Each node generates an Ed25519 identity key pair at first boot. The private key lives in /var/lib/hermes/identity.key, mode 0600, optionally sealed to a TPM or to the Pi's OTP if the hardware supports it. The public key is the node's cryptographic identity; the call sign is its human and legal identity, and binding the two is the trust problem.
Three binding mechanisms, in increasing order of strength, all supported:
Trust on first use. The first time a call sign is seen with a key, the binding is recorded and pinned. A later key change for the same call sign raises a loud, non-dismissible warning in the UI and marks all traffic from that identity as unverified until the operator accepts the change. This is adequate for a hobby network and is the default.
Hub attestation. A hub operator verifies a license (FCC ULS lookup plus an emailed challenge to the address of record, which is public) and issues a signed attestation binding call sign to public key with an expiry. Nodes carry attestations and present them. Hubs cross-sign each other, which is how federation works.
Out of band fingerprint. A 40 bit fingerprint rendered as eight base 32 characters and as a QR code in the UI, verified over the phone, in person at a club meeting, or on a repeater. This is the mechanism that survives the internet being gone, and it is the one to use for the stations that matter most, before the emergency.
Key rotation and backup. The identity key exists in one place, on an SD card, in a device that runs continuously. Losing it is a when, and losing it means losing every pinned trust relationship the station has built. Two mechanisms address that.
Encrypted backup. hermesctl identity backup writes the key sealed with a passphrase-derived key (scrypt, then ChaCha20-Poly1305). The station identifier is authenticated but not encrypted, so an operator with three sticks in a drawer can tell which is which without typing a passphrase. Restore refuses to overwrite a live identity unless forced, and keeps the superseded key rather than destroying it.
Rotation certificates. The old key signs a statement naming the new one. A peer holding the old key verifies it without asking anyone; a peer that never held the old key gains nothing and falls back to trust on first use, which is correct, because a rotation proves continuity rather than identity. A certificate that chains from a key we do not hold is recorded and not applied, and the operator is told why in words.
The interaction with 13.4 matters: a key change accompanied by a valid rotation certificate is not an identity conflict and is not reported as one.
Addresses come in four forms:
| Form | Example | Meaning |
|---|---|---|
| Station | K9MEV-3 |
One instance |
| Call sign | K9MEV |
Any instance, first to acknowledge takes custody |
| Group | @FRRL, @KANECO, @ARES-IL |
All subscribed members, no delivery guarantee to any individual |
| Service | !EMAIL, !NTS, !APRS |
A gateway that translates outward |
Group names are 2 to 8 uppercase characters and map directly onto JS8Call group call signs, so a HERMES group broadcast is visible to and usable by ordinary JS8Call operators who happen to be monitoring that group. Interoperability with plain JS8Call users is a design goal throughout, and is discussed in 9.9.
Prices are August 2026 street prices in USD and are indicative.
Core node
| Item | Part | Qty | Unit | Notes |
|---|---|---|---|---|
| Transceiver | QRP Labs QMX, 80/60/40/30/20 m, assembled | 1 | 152.47 | Kit at 102.47 if building |
| Computer | Raspberry Pi 5, 2 GB | 1 | 50 | Pi 4 2 GB acceptable, see 6.2 |
| Storage | 32 GB A2 microSD (industrial grade) | 1 | 15 | Or 128 GB NVMe on Pi 5 HAT |
| Time | u-blox 7 or 8 USB GPS with PPS | 1 | 18 | Mandatory, see 6.7 |
| Clock | DS3231 RTC module with CR2032 | 1 | 5 | Cold start without GPS |
| Power | 12 V to 5 V 5 A buck (MP1584 or Pololu D24V50F5) | 1 | 12 | Low noise part, see 6.7 |
| Power | Anderson Powerpole set, fuse holder, 5 A ATC fuse | 1 | 10 | |
| Cabling | Short USB-C to USB-A, right angle | 1 | 6 | Pi to QMX |
| RF | BNC bulkhead to BNC pigtail, RG-316 | 1 | 8 | |
| Suppression | Fair-Rite 0431164281 clamp-on ferrites | 6 | 2.50 | See 6.8 |
| Shielding | Copper foil tape, 50 mm, adhesive conductive | 1 | 10 | Enclosure lining |
| Enclosure | PETG or ASA filament, approx 220 g | 1 | 6 | |
| Hardware | M3 heat set inserts (x14), M3 screws, standoffs | 1 | 8 | |
| Front panel | 3 mm acrylic window, 20 x 60 mm | 1 | 3 | Over QMX LCD |
| Indicators | 5 mm light pipes (x3), WS2812 or discrete LEDs | 1 | 5 | |
| Core subtotal | ~310 | 260 with QMX kit self-built |
Station completion
| Item | Part | Qty | Unit | Notes |
|---|---|---|---|---|
| Antenna | 40/30/20 m fan dipole: wire, 1:1 balun, spreaders, insulators | 1 | 55 | Default, see 6.6 |
| Antenna | Alternative: 40 m linked EFHW with 49:1 transformer | 1 | 45 | Portable option, not the default |
| Feedline | 15 m RG-58 or RG-8X with BNC ends | 1 | 25 | |
| Balun | FT240-43, 12 bifilar turns, 1:1 current balun at the feedpoint | 1 | 14 | Common mode control |
| Battery | 12.8 V 20 Ah LiFePO4 with BMS | 1 | 80 | 256 Wh |
| Charging | 50 W folding panel plus 10 A MPPT controller | 1 | 70 | |
| Deployment | Throw line, 30 m paracord, weight, stakes | 1 | 20 | |
| Station subtotal | ~275 |
Optional receive-only SDR package (recommended, see 6.9)
| Item | Part | Qty | Unit | Notes |
|---|---|---|---|---|
| Receiver | RTL-SDR Blog V4 with dipole kit | 1 | 40 | Direct sampling HF, the default choice |
| Receiver | Alternative: Airspy HF+ Discovery | 1 | 169 | Far better dynamic range, for relay and backbone nodes |
| Receiver | Alternative: SDRplay RSP1B | 1 | 110 | 14 bit, wider coverage, middle option |
| T/R switch | 12 V SPDT RF relay board, keyed from the QMX PTT out jack | 1 | 15 | Only if sharing the transmit antenna |
| Protection | SMA gas discharge tube, back to back 1N4148 pair, 100 ohm series | 1 | 8 | Mandatory when sharing, see 6.9 |
| RX antenna | 10 m insulated wire plus 9:1 unun, if using a separate receive antenna | 1 | 20 | Simpler and safer than sharing |
| Cabling | RG-316 SMA to BNC pigtail | 1 | 7 | |
| SDR subtotal | ~70 to 90 | With the RTL-SDR and one of the two connection methods |
Total for a complete, off-grid capable station: approximately 585 USD, or approximately 480 USD building the QMX from the kit and reusing an existing battery. Adding the receive-only SDR brings the figure to roughly 660 USD, and it is the single best 70 USD that can be added to a relay node.
| Option | Idle power | JS8Call decode headroom | Recommendation |
|---|---|---|---|
| Pi Zero 2 W | 0.7 W | Marginal. Multi-decode across four speeds will drop frames under load | Austere variant only, single speed decode, no web assets built locally |
| Pi 4, 2 GB | 2.7 W | Comfortable | Best power to capability ratio, recommended for solar |
| Pi 5, 2 GB | 3.5 W | Ample, room for future SSTV or Winlink gateway | Recommended default, active cooler required |
The Pi 5 is the default because HERMES is doing more than running JS8Call: it is serving a web application, running a database, maintaining IP sessions, and decoding continuously. The extra watt buys a decade of headroom. The Pi 4 is the right answer for a node that must live on a 20 W panel through an Illinois December.
The Pi Zero 2 W variant is documented but constrained: it runs JS8Call with the multi-speed decoder disabled, serves a reduced UI, and is intended for a "go bag spare" role rather than a permanent installation.
The QMX presents to the Pi over a single USB-C cable as three devices: a 24 bit 48 ksps USB sound card, a virtual COM port for CAT (Kenwood TS-480 command dialect), and, in recent firmware, up to two additional virtual COM ports. This single-cable property is the reason the QMX is the right radio for this design. There is no sound card to buy, no VOX to configure, no ground loop between an external interface and the radio, and no analog audio path to pick up hash from the Pi.
Band build. The 80/60/40/30/20 m build is recommended. It covers all four of the automatic control segments that matter at HF (3.585-3.600, 7.100-7.105, 10.140-10.150, 14.1005-14.112) and gives a working set that spans night regional (80), day and night regional (40), the reliable 24 hour NVIS-to-medium-haul band (30), and daytime medium haul (20). The 20/17/15/12/11/10 m build is a poor fit: it lacks any usable night regional path for Illinois, and the entire premise of a regional emergency network is regional paths.
Mode. Digi mode (single-signal FSK), not SSB. The QMX in Digi mode generates a pure FSK carrier from the incoming audio tone with no unwanted sideband, no residual carrier, and no intermodulation, which is exactly what JS8 needs and is meaningfully cleaner than an SSB modulator at 5 W. JS8Call is configured for a low audio drive level, and the QMX firmware displays an error indicator when audio is insufficient, which hermes-radiod monitors.
Power output. 3 to 5 W at 12 V, drawing approximately 0.7 A on transmit and 80 mA on receive. The 5 W figure is realistic and is the basis for the link budget in section 15.5.
CAT usage by hermes-radiod. The service uses these commands (firmware 1_04_004 and later):
| Command | Use in HERMES |
|---|---|
FA / FB |
Set and read dial frequency for band changes |
BN |
Set band by band configuration table column, preferred over raw frequency |
MD |
Confirm Digi mode, and MD8 to enter SWR tune mode during qualification |
SW |
Read SWR meter during band qualification sweeps |
PC |
Read power meter, verify actual output before trusting a link estimate |
SM |
Read S meter for noise floor characterization per band per hour |
SR |
Read and reset SWR protection state, critical for unattended operation |
TM |
Read and set the real time clock |
GP |
Read GPS position, date, and time when a GPS is attached to the radio |
UI |
Read the STM32 unique serial number, used as a hardware asset tag |
PS |
Power supply status and orderly shutdown |
IF |
General status polling |
The SR command is the one that makes unattended operation safe. If the antenna is damaged (a wire down after the same storm that took out the internet, which is precisely the scenario this system exists for), the QMX SWR protection trips and stays tripped. Without polling SR, the node would happily believe it was transmitting for hours. hermes-radiod polls SR after every transmission, records a protection event, marks the band as failed, attempts one reset, and after two failures on two different bands raises an antenna fault alarm in the UI and over the internet path if available.
The QMX is the reference radio and the one the enclosure, the parts list, and the test plan are built around. Two other QRP Labs transceivers are supported, because supporting them costs one driver class and materially widens the population of operators who can build a node from a radio they already own.
hermes-radiod implements a driver per model behind a capability interface. A driver declares what it can do, and the rest of the system adapts rather than assuming:
class RadioDriver:
model: str
bands: list[int] # metres
max_power_w: float
has_swr_bridge: bool # band qualification available
has_smeter: bool # noise floor characterization available
has_gps_input: bool
has_ptt_out: bool # can key an external T/R switch
band_switch: str # "cat_band" | "cat_freq"
standalone: bool # usable without a computer attached
| QMX (reference) | QMX+ | QDX / QDX-M | |
|---|---|---|---|
| Bands | 80/60/40/30/20 (confirmed build) | 160 through 6 m | 4 bands per build, commonly 80/40/30/20 |
| Power | 3 to 5 W | 5 W with more headroom | 4 to 5 W |
| Modes | CW, digi FSK, SSB | CW, digi FSK, SSB | Digi FSK only |
| Sound card and CAT over one USB | Yes | Yes | Yes |
| SWR bridge | Yes | Yes | No |
| S meter over CAT | Yes | Yes | No |
| Power meter over CAT | Yes | Yes | No |
| PTT out for a T/R switch | Yes | Yes | No |
| GPS input | Yes | Yes | No |
| Front panel and standalone use | Yes | Yes | No, headless by design |
| Approximate cost | 102 kit, 152 assembled | 200 kit range | 79 to 99 |
| HERMES support tier | Reference | Full | Reduced, see below |
QMX+. Functionally a superset. Same firmware family, same CAT dialect, same single-cable USB behavior, so the driver differences amount to the band table and the power ceiling. It adds 160 m and 6 m, neither of which has an automatically controlled digital station segment that this design uses at HF, but 160 m is a genuinely useful regional night band and 6 m is fully available for automatic control under 97.221(b), which makes a QMX+ node interesting for a metropolitan-scale network on 6 m in a way the QMX cannot be. The enclosure needs a wider radio compartment; the parametric source in appendix E has the compartment dimensions as variables, and the QMX+ figures must be taken from its own mechanical drawing rather than guessed.
QDX and QDX-M. Digital-only, cheap, and headless, which is exactly the shape of this application. Everything HERMES actually needs on transmit and receive is present. What is lost is instrumentation, and the losses are worth stating plainly because they degrade two features that this design leans on:
RIG.PTT event, which is riskier because it depends on software timing to protect a 30 USD receiver from 5 W.A QDX node is a fully participating HERMES node with less self-knowledge. For a club buying ten nodes on a budget, that is a reasonable trade. For an unattended node at a remote site, the SWR bridge is worth the extra fifty dollars.
12 V in ---[fuse 5A]---[reverse polarity]---+--- QMX 2.1 mm barrel
|
+--- Buck 12->5 V ---> Pi USB-C PD in
|
+--- ADC (INA219) ---> Pi I2C (voltage/current telemetry)
Pi USB-A --[ferrite]--[ferrite]-- QMX USB-C (audio + CAT)
Pi USB-A ------------------------ GPS dongle (time + position)
Pi I2C ------------------------ DS3231 RTC, INA219
Pi GPIO 4 ----------------------- GPS PPS in
Pi GPIO 17/27/22 ---------------- status LEDs (power, RF active, internet)
Pi GPIO 26 ---------------------- momentary button (safe shutdown / AP mode)
QMX BNC --[bulkhead]------------- antenna
The QMX and the Pi share a common 12 V source but are fed on separate branches from the fuse block, with the buck converter's input decoupled by a 470 uF low ESR capacitor and a common mode choke. The single most important assembly detail is that the QMX gets 12 V directly, not through the Pi's supply, so that the 0.7 A transmit current step does not modulate the Pi's rail.
The requirement is inexpensive, multi-band, and no tuning. There is no antenna tuner in the QMX, and the QMX SWR protection will simply refuse to transmit into a bad match, so the antenna must present a usable impedance on every band the node intends to use, without operator action.
The default is a 40/30/20 m fan dipole. It covers exactly the three bands whose automatically controlled digital station segments this design uses in practice, it needs no walking to the far end of a wire to change bands, and it can be built for about 55 USD.
| Element | Half length per leg | Total tip to tip | Trim order |
|---|---|---|---|
| 40 m, resonant near 7.10 MHz | 10.06 m (33 ft 0 in) | 20.1 m | Third |
| 30 m, resonant near 10.14 MHz | 7.06 m (23 ft 2 in) | 14.1 m | Second |
| 20 m, resonant near 14.10 MHz | 5.04 m (16 ft 6 in) | 10.1 m | First |
Construction notes that matter:
Documented alternative: the 40 m linked end fed half wave. For portable and go-bag use, where one support and one throw line is the whole installation, a 20.1 m end fed half wave with a 49:1 transformer on an FT240-43 core, a 100 pF capacitor across the primary, and a 2 m counterpoise gives resonant operation on 40, 20, 15, and 10 m. Adding a link and a further 20.5 m of wire makes it an 80 m antenna covering 80, 40, 20, 15, and 10 m. It is cheaper and far quicker to deploy. It is not the default, for two reasons: it does not cover 30 m, which is the band this design prefers whenever it is open, and changing between 80 and 40 means walking to the far end of a wire in whatever conditions produced the emergency.
SWR qualification. On first boot, on any configuration change, and every 24 hours thereafter, hermes-radiod sweeps each enabled band using the QMX built-in SWR bridge (MD8 to enter tune mode, SW to read, three points across each segment of interest, at reduced drive), records the result in the band_swr table, and marks bands above a configurable threshold, default 2.5:1, as unusable. The router will not select an unusable band. This turns "did the antenna fall down" from an unanswered question into a monitored, alarmed condition, and it makes the system tolerant of whatever wire the operator actually managed to get into a tree. On a QDX, which has no SWR bridge, this feature is unavailable and the fallback described in 6.4 applies.
Time discipline is not optional and is the single most under-appreciated requirement in this design. JS8 requires transmit and receive windows aligned to within roughly 2 seconds. The Pi has no real time clock. In the scenario this system exists for, there is no NTP because there is no internet. A node that has been running on battery for three days with a drifting clock is a node that cannot decode anything, and it will not know why.
The solution is a three tier clock:
gpsd, with PPS on GPIO 4, disciplined by chrony. Accuracy in the microseconds, entirely independent of any infrastructure. Approximately 18 USD and 0.3 W.@TIME group drift synchronization as a last resort. JS8Call can estimate time drift from decoded signals and HERMES can consume that estimate through the API to at least warn the operator that the clock is wrong even when nothing else is available.A wrong clock must be visible, not merely fatal. A node whose GPS has failed and whose real time clock has drifted does not stop: it transmits in the wrong windows, decodes nothing, and the operator sees "no decodes" and starts checking the antenna. The node therefore samples chrony every five minutes, records the offset rather than a boolean (because "synchronized" is not the useful question: a node can be synchronized to a source that is itself wrong), warns at 0.5 s of drift, fails at 1.5 s, and puts a clock card on the dashboard beside battery and antenna. The failure message names the two commands that diagnose it.
The GPS also supplies the Maidenhead grid square automatically, which populates the JS8Call station configuration, feeds the neighbor database with real distances, and lets the router reason about paths geographically (a 900 km path at 0300Z is a 40 m path, not a 20 m path).
The QMX has its own GPS input on the paddle jack for frequency calibration and clock setting, and a second GPS could be fitted, but a single dongle on the Pi with the Pi setting the QMX clock over CAT (TM) is the cheaper and simpler arrangement.
A Raspberry Pi 30 cm from a QRP receiver front end is a genuine engineering problem, and it will be the difference between hearing a station at -22 dB and not hearing it at all. A 5 W station cannot afford to lose 6 dB of receive sensitivity to its own computer.
Mitigations, in order of effectiveness per dollar:
vcgencmd display_power 0), and the Pi 5 UART console disabled after commissioning.band_noise table through the QMX SM command. If the difference exceeds 3 dB, the build is not finished. This test is scripted and is part of the acceptance procedure in section 17.The QMX hears one band at a time. That constraint is the reason section 9.3 needs a band schedule, and the band schedule is the largest single compromise in the RF design: a node monitoring 40 m is deaf to a station calling it on 30 m, and both stations waste transmissions discovering that.
A cheap receive-only software defined radio removes the receive half of that compromise entirely. A node with an SDR listens on every band at once and transmits on one, which changes the network from a set of stations that must agree on where to meet into a set of stations that can be found wherever they happen to be calling.
What it buys, concretely:
| Receiver | Cost | HF performance | Recommendation |
|---|---|---|---|
| RTL-SDR Blog V4 | 40 | 8 bit, direct sampling HF, adequate with a modest antenna, will overload near strong broadcast signals | The default. Buy it, add an attenuator or a broadcast band reject filter if 49 m band overload appears |
| SDRplay RSP1B | 110 | 14 bit, much better dynamic range, wide coverage | Middle option, good for a home node in a noisy suburb |
| Airspy HF+ Discovery | 169 | Excellent HF dynamic range, the right answer in a crowded band | Backbone and relay nodes, where hearing weak stations under strong ones is the whole job |
Connecting it to an antenna, which is where the danger lives. Five watts into an RTL-SDR input destroys it instantly. There are two safe approaches and one unsafe one.
Approach A, separate receive antenna (recommended, and the default in the parts list). A 10 m insulated wire with a 9:1 unun, or a small passive loop, on its own coax. It costs 20 USD, it can never be connected to the transmitter, and it decouples the receive chain from any transmit antenna fault. A receive antenna does not need to be efficient, because on HF below 20 m the received noise floor dominates, not the antenna gain.
Approach B, shared antenna with a hardware T/R switch. A 12 V SPDT RF relay board, keyed by the QMX PTT out jack, disconnects the SDR and grounds its input during transmit. Note that Rev 1, 2 and 3 QMX PCBs need the QRP Labs grounded PTT output modification for this jack to work, and Rev 4 and later do not. Whichever approach is used, the SDR input gets a protection stack regardless: a gas discharge tube to ground, a 100 ohm series resistor, and back to back small signal diodes to ground. Relays bounce, firmware hangs, and cables get swapped, and the protection stack is what stands between a moment of any of those and a dead receiver.
The unsafe approach, which is not supported: keying a switch from software timing alone, with no hardware interlock from the transmitter. A QDX node has no PTT output and therefore has no safe way to share the transmit antenna, so a QDX node uses approach A.
Software. A new service, hermes-scand:
rtl_sdr / SoapySDR -> ka9q-radio or csdr channelizer
-> N virtual sound devices (snd-aloop)
-> N headless JS8Call instances, transmit disabled,
one per band, API ports 2443, 2444, 2445 ...
-> hermes-rfd collects decodes into observations
Every scan instance has transmit hard-disabled in three places: the JS8Call configuration, a hermes-scand policy flag, and the absence of any transmit path in the hardware. This is deliberate belt, braces, and a third fastener, because a receive-only chain that somehow transmits is both an interference incident and a violation.
Cost in power and CPU. The RTL-SDR draws about 1.3 W, and each additional decoder instance costs roughly 0.4 W of CPU on a Pi 5. Two extra bands is comfortable. Three or four needs measurement, and is a phase 8 test item. On a solar-powered node the honest arithmetic is that the SDR package adds roughly 2 to 2.5 W, or 55 Wh per day, which is a 40 percent increase over the base budget in 6.10 and needs a bigger panel. On mains power, fit it to every node. On solar, fit it to relay nodes and size the panel accordingly.
At 12.0 V nominal:
| State | QMX | Pi 5 (via buck at 88 percent) | GPS | Total | Notes |
|---|---|---|---|---|---|
| Receive, idle | 0.08 A | 0.33 A | 0.03 A | 0.44 A (5.3 W) | Continuous baseline |
| Receive, decoding | 0.08 A | 0.45 A | 0.03 A | 0.56 A (6.7 W) | Multi-speed decode bursts |
| Transmit | 0.70 A | 0.40 A | 0.03 A | 1.13 A (13.6 W) | 5 W out |
Duty cycle assumptions for a node in a quiet posture: 96 percent receive idle, 3 percent decoding, 1 percent transmit. Average draw approximately 0.45 A, or 5.5 W, or 132 Wh per day.
With the Pi 4 substituted, the figures fall to roughly 0.33 A average, 4.0 W, and 96 Wh per day.
| Battery | Capacity | Pi 5 runtime | Pi 4 runtime |
|---|---|---|---|
| 12.8 V 20 Ah LiFePO4 | 256 Wh | 1.9 days | 2.7 days |
| 12.8 V 50 Ah LiFePO4 | 640 Wh | 4.8 days | 6.7 days |
For indefinite operation, a 50 W panel in Batavia yields roughly 150 to 200 Wh per day averaged over a year and considerably less in December, when the practical figure is closer to 60 to 90 Wh per day for a fixed south-facing panel. A 100 W panel and a 50 Ah battery is the correct specification for a genuinely indefinite winter installation in northern Illinois, and the 50 W and 20 Ah figures in the bill of materials should be understood as a summer or short-duration configuration.
If the receive-only SDR package of 6.9 is fitted, add approximately 2.0 to 2.5 W continuous, or roughly 55 Wh per day, and size the panel and battery accordingly. That is the honest cost of the single most useful optional addition in this design.
Power monitoring, and the interlock that depended on it. An INA219 on the I2C bus reports bus voltage and shunt current, from which the node derives power, a coarse state of charge against a per-chemistry curve, and an estimate of hours remaining. It is optional, because most nodes are on mains and the part can be left off the board, and it is enabled with power.monitor.enabled.
What is not optional is honesty about what depends on it. Earlier revisions specified a battery floor interlock on software updates, and the first implementation only evaluated it when a number was passed by hand, which nobody does. An interlock that never fires is worse than none, because it creates the belief that the node is protecting itself. The interlock now reads the most recent sample, treats a reading older than fifteen minutes as unknown rather than as full, and defers the update when the battery is genuinely below the floor.
The state of charge is deliberately coarse and labelled as such. A proper fuel gauge needs coulomb counting, and a coarse number that is honestly labelled beats a precise one that is wrong under load. Terminal voltage sags under transmit, so the estimate adds back a correction rather than reporting a node mid-transmission as about to die.
A power-saving posture is implemented for battery operation: the Pi drops to a reduced governor, JS8Call decodes only Normal and Slow, and the node adopts a duty schedule (listen for 10 minutes in every 30, transmit heartbeats every 60 minutes rather than every 15). This roughly halves consumption at the cost of latency, and the router accounts for scheduled sleep when estimating delivery time. The schedule is advertised in the capability beacon so that peers know when to try.
Most emergency communications traffic travels five miles, not three hundred. A shelter reports to the county emergency operations center, a hospital reports to the health department, a damage assessment team reports to the incident commander. Building an HF-only messaging network for that job is a design error of scale, and revision 1.1 made it by treating VHF as a future upgrade rather than as a transport.
A VHF or UHF link is added as a third transport, and on a node that has one it becomes the preferred RF path. Three facts make this much easier than the HF side:
K9MEV-1 is native, costs nothing, and needs none of the compound call sign machinery of 5.2. The instance number maps directly onto the SSID.| Option | Speed | Hardware | Recommendation |
|---|---|---|---|
| AX.25 at 1200 baud, Direwolf software TNC | 1200 bps | Any FM radio plus a sound interface | The default. Mature, forgiving of cheap radios, works through existing digipeaters |
| AX.25 at 9600 baud | 9600 bps | A radio with a true 9600 data port | Better where the radios support it, which most cheap ones do not |
| JS8 over FM simplex | 40 to 90 cpm | Same as HF | Works, and there is no reason to accept HF speeds on a line of sight path |
| LoRa bridge, Meshtastic or MeshCore | Low | LoRa module | Interesting as a fourth transport for last-hundred-metres and for unlicensed participants |
| AREDN or Wi-Fi mesh | Megabits | Rooftop dishes, line of sight | The right answer between fixed sites that can see each other, and a different project |
Recommended hardware, added to the parts list as an option:
| Item | Part | Qty | Unit |
|---|---|---|---|
| Radio | Used commercial 2 m mobile, 25 W, or an inexpensive dual band mobile | 1 | 60 to 150 |
| Interface | Digirig Mobile, or a USB sound interface plus a PTT cable | 1 | 60 |
| Antenna | 2 m or dual band ground plane, plus coax | 1 | 60 |
| Power | Uprated DC branch and fuse, 10 A allowance for transmit | 1 | 10 |
Software. A new service, hermes-vhfd, running Direwolf as a KISS TNC on a serial or network socket, carrying HMP fragments in AX.25 UI frames with the same session, fragmentation, and acknowledgment logic as the HF transport. Connectionless UI frames rather than connected AX.25 mode, because HERMES already has its own selective repeat scheme and layering one retransmission mechanism over another produces the worst of both.
Power is the honest cost. A 25 W mobile draws 8 to 10 A on transmit. The duty cycle is very low, since transmissions last seconds rather than minutes, so the energy cost is small, but the peak current means a bigger fuse, thicker wire, and a battery that can supply it. A 5 W handheld with a proper interface is a perfectly reasonable alternative for a node that only needs to reach a repeater or a neighbor a few miles away.
Digipeaters and existing infrastructure. A node can route through existing AX.25 digipeaters exactly as APRS does, which means an established regional packet network becomes usable HERMES infrastructure without anybody's permission and without any new hardware. Where a local group already runs a packet BBS or a digipeater on a good site, that site is already a HERMES relay.
The QMX bare PCB fits an enclosure of 95 x 63 x 25 mm without protrusions, which sets the minimum dimension of the radio compartment. The Pi 5 board is 85 x 56 mm and needs 25 mm of height with an active cooler. The QMX front panel carries the LCD, two rotary encoders with push, and 3.5 mm jacks; the rear carries the BNC, the 2.1 mm power jack, and USB-C.
The design decision is whether to enclose the bare QMX PCB or to place the QMX in its own aluminum enclosure inside a printed shell. Enclose the bare PCB. Nesting an aluminum box inside a printed box wastes 40 mm in every dimension, doubles cost, and produces something that looks like a project rather than a product. The trade is that the printed shell must provide the mechanical protection and the electromagnetic mitigation that the aluminum would have given, which is what the copper foil lining and the internal partition in 6.8 are for.
Overall envelope: 175 mm wide x 125 mm deep x 62 mm high, excluding the BNC and knobs.
FRONT REAR +---------------------------------+ +---------------------------------+ | [==== QMX LCD window ====] | | (o)DC [USB][USB] [ETH] | | | | | | (O) (O) * * * | | (O) BNC [vent] | | TUNE VOL P R I | | | | | | | | +-------------------------+ | +---------------------------------+ | | K 9 M E V - 1 | | | +-------------------------+ | <-- swappable call sign plate +---------------------------------+
Aesthetic direction, since visual appeal is an explicit requirement:
Two compartments separated by a 3 mm printed partition:
| Parameter | Value | Reason |
|---|---|---|
| Material | PETG (indoor) or ASA (vehicle, outdoor cabinet) | Heat resistance above PLA, less brittle than ABS |
| Layer height | 0.2 mm, 0.16 mm on the faceplate | Faceplate is the visible surface |
| Walls | 4 perimeters (1.6 mm) | Stiffness and screw boss strength |
| Infill | 25 percent gyroid | Isotropic, quiet, adequate |
| Top and bottom | 5 layers | Solid surface for fuzzy skin |
| Supports | None required by design | Every overhang is at 45 degrees or bridged under 8 mm |
| Inserts | M3 x 5 x 4 mm brass heat set, 14 places | Repeated disassembly without stripping |
| Print time | approximately 14 hours total, 3 parts | |
| Material | approximately 220 g |
Appendix E contains a parametric OpenSCAD skeleton with every dimension exposed as a variable, including a verify_against_mechanical_drawing block listing the seven measurements that must be confirmed against the actual QMX PCB revision before the first print, because QMX PCB revisions have moved connector positions.
/var/lib/hermes is always a separate ext4 partition mounted data=journal, which is what makes power removal survivable.xvfb with the GUI never rendered, controlled exclusively through its TCP API. This is a well-trodden path and is how every JS8Call automation project operates.hermes-core owns the message store and is the only writer to the messages table. It runs the router (9.6), the scheduler, the receipt engine, and the audit log. It is deliberately transport-ignorant.
hermes-rfd maintains a persistent TCP connection to the JS8Call API, subscribes to RX.DIRECTED, RX.ACTIVITY, RX.SPOT, RX.CALL_ACTIVITY, and RIG.PTT, and issues TX.SEND_MESSAGE, RIG.SET_FREQ, MODE.SET_SPEED, and STATION.SET_*. It implements the framing, fragmentation, and ARQ described in 9.4, maintains the transmit budget, and feeds every decode into the observations table whether or not it is HERMES traffic. That last point matters: a node accumulates a propagation database from ordinary JS8Call activity as well as from its own network, which is what makes the database useful within days rather than months.
hermes-netd registers with the configured hubs, maintains a WebSocket to each, holds direct sessions to peers it can reach, and performs the reachability probing that populates the internet side of the station database. It also handles the case that matters most in practice: a node behind CGNAT with no inbound reachability at all, which is the majority case, and which is exactly why a hub exists.
hermes-radiod owns the CAT port. Nothing else touches it. It arbitrates band changes against the transmit budget, runs the SWR and noise sweeps, enforces the interlock (no band change during transmit, no transmit when SWR protection is latched, no transmit on 60 m unless the attended flag is set), and publishes radio telemetry to the bus.
hermes-web serves the UI and the REST API, holds WebSockets to browsers, and enforces authentication and authorization. It never touches the radio or the message store directly; it calls the core over the bus. This separation means a compromised web layer cannot transmit.
Full DDL is in appendix A. The important structures:
stations is the roster: canonical identifier, base call sign, instance, public key, key state (pinned, attested, unverified), display name, grid, capabilities, and the last known values of everything. One row per station, ever.
observations is the raw log: every time we heard anything from anyone, with timestamp, band, dial frequency, offset, SNR, speed, mode, and whether the decode was addressed to us. This table grows and is the most valuable thing the node produces. Retention is 400 days by default with hourly rollup after 30 days, which keeps a full seasonal cycle at full resolution and a multi-year record in summary.
links is the aggregation: for each (peer, band, hour-of-day-UTC, month) tuple, the count of decodes, the mean and best SNR, the success rate of attempted exchanges, and a computed quality score. This is what the router actually reads. It is rebuilt incrementally, not recomputed.
reachability records the IP side: last successful direct connection, last hub-mediated contact, observed latency, whether the peer accepts inbound connections, and the peer's advertised duty schedule.
messages, message_parts, receipts, custody, and transmissions carry the traffic and its proof. transmissions in particular records every frame the node sent, with timestamps, which is both the ARQ state and a defensible operating log.
band_swr, band_noise, tx_budget, config, users, and audit_log complete the schema.
SQLite, WAL mode, one file, approximately 40 MB after a year of typical operation. It is trivially backed up (the backup button in the UI produces a single signed tarball), trivially inspected, and needs no administration.
Served over TLS on port 443 with a self-signed certificate by default, replaced by a real certificate when the node has a name and reachability. Reachable three ways, in this order of preference: over the LAN at https://hermes-k9mev-1.local through mDNS, over a Tailscale or WireGuard overlay if configured, and over the node's own Wi-Fi access point at https://192.168.44.1 when it detects no usable network. The 44 in that address is not an accident and the AP SSID defaults to HERMES-<callsign>.
First boot wizard, six screens, each of which can be revisited later:
Main screens:
Attended mode is a toggle with a countdown, defaulting to 60 minutes, extendable, that asserts a control operator is present. It unlocks the conventional calling frequencies, higher speeds, 60 m, and third party traffic. It expires by itself, because an operator who forgets is the normal case, and an expired assertion returns the node to a legal unattended posture rather than leaving it in an illegal one.
Progressive web app, installable, fully functional offline against the local node, with the message composer working while disconnected and syncing when the browser reconnects. This matters because the operator's phone may be the only user interface available.
A REST API under /api/v1 with a WebSocket at /api/v1/events for push. Full listing in appendix C. Notable design choices:
admin (everything), operator (compose, send, attend), and observer (read only). A served agency volunteer gets operator, not admin.localhost:2525 lets a local mail client submit a message, for operators who want to use a familiar tool.Security is treated as a first-class part of the design rather than a section at the end, and it is documented in full in section 13, which covers the attack surface inventory, the node hardening baseline, the network abuse controls, and the hub hardening requirements. Two properties belong here because they shape the software architecture itself:
hermes-web cannot transmit. It runs as its own unprivileged user with no access to the CAT serial port, no access to the JS8Call socket, and no ability to reach the radio except by asking hermes-core over the bus, where every request is validated independently. A compromise of the web layer is a serious problem, and it is not a transmitter.The target is an operator who has just written Raspberry Pi OS Lite to a card, booted it, and logged in over SSH. From there, HERMES installs in three commands.
A signed APT repository carries every component. The repository key is distributed as a keyring package and as a fingerprint published in this document and on the project site, so that a fresh install can be verified against something other than the server it came from.
# 1. Trust the repository key curl -fsSL https://apt.<project-domain>/hermes-archive-keyring.gpg \ | sudo tee /usr/share/keyrings/hermes-archive-keyring.gpg > /dev/null # 2. Add the repository echo "deb [signed-by=/usr/share/keyrings/hermes-archive-keyring.gpg] \ https://apt.<project-domain>/hermes trixie main" \ | sudo tee /etc/apt/sources.list.d/hermes.list # 3. Install sudo apt update && sudo apt install hermes-node # 4. Configure, interactively, once sudo hermes-setup
hermes-setup asks for the call sign, instance number, and grid, forces the creation of a web administrator password, generates the identity key pair, prints the fingerprint, and prints the URL to open. Everything else happens in the browser wizard of 8.4.
Packages:
| Package | Contents |
|---|---|
hermes-common |
Message library, canonicalization, crypto, schema, shared config |
hermes-core |
Message store, router, receipts, scheduler |
hermes-web |
User interface, REST API, PWA assets |
hermes-rfd |
JS8Call transport |
hermes-radiod |
Radio drivers for QMX, QMX+, QDX |
hermes-netd |
IP transport |
hermes-scand |
Receive-only SDR scanning, pulls in rtl-sdr or soapysdr |
hermes-hub |
The hub service, for anyone running one |
hermes-doc |
This document and the operator manual, offline |
hermes-node |
Metapackage: common, core, web, rfd, radiod, netd, plus js8call |
hermes-node-sdr |
hermes-node plus hermes-scand |
What the packages do and deliberately do not do. The postinstall creates the service users, creates /var/lib/hermes and /etc/hermes, and installs the systemd units disabled. It does not generate keys, does not open a port, does not start a listener, and does not set a password. A machine that has hermes-node installed but has never had hermes-setup run on it is inert. Keys are generated at first setup, on the machine, never at package build or install time, so that no two nodes can ever share an identity.
Configuration lives in /etc/hermes/hermes.yaml as a dpkg conffile, so an upgrade never silently overwrites an operator's settings.
On the convenience one-liner. A curl | sudo bash installer is published because people will ask for one, and the documentation says plainly that the four commands above are the preferred path, prints the script's SHA-256 next to the link, and explains that piping a remote script into a root shell means trusting the server completely at that instant. Offering it and being honest about it is better than pretending nobody does it.
Other platforms. The same packages build and run on Debian and Ubuntu on x86-64, which is how a hub gets deployed on a virtual machine and how a test node runs on a laptop without a radio.
An emergency communications appliance that is three years behind on security updates is a liability sitting on a public address. An appliance that reboots into a broken kernel during an activation is worse. This section resolves the tension.
Two layouts.
Layout A, plain read-write root. The default for a node the operator can physically reach. OS security updates come from unattended-upgrades with the Debian security suites plus the HERMES repository. HERMES updates come from apt. Nothing exotic.
Layout B, read-only root with an overlay. The recommended layout for an unattended field node, and the one that needs the machinery below. The root filesystem is mounted read-only with a tmpfs overlay, so an abrupt power loss can never corrupt it, and no runtime process can modify the system.
The update pipeline, identical in both layouts, run by hermes-update on a timer:
A/B slots on the Pi. The Pi 5 and Pi 4 bootloaders support a try-boot mechanism: boot the new slot once, and if the running system does not confirm success by setting a flag, the next boot reverts automatically. A node that receives a bad kernel update comes back on the old kernel by itself, without a site visit, which for a node in a park district building 40 km away is the difference between an inconvenience and a lost node.
Reboots are policy-controlled: never, window, or required-only. A node that needs a reboot announces it to its peers as a status message with the expected downtime, so the network's routing does not spend that window trying to reach it, and defers if it is carrying custody of anything above routine precedence.
Offline and sneakernet updates. This is not optional in a system whose premise is that the internet fails. A signed update bundle can be built by anyone with the repository contents and applied from removable media:
sudo hermes-update --bundle /media/usb/hermes-2026-09-security.hupd
The bundle is signed with a separate offline signing key whose public half ships with the packages. Bundle application runs the same verify, apply, health check, and rollback pipeline as a network update. A volunteer walking a USB stick to six nodes after a two-week outage is a supported and documented workflow.
Configuration.
vhf: # section 6.11
enabled: false
tnc: direwolf
kiss: 127.0.0.1:8001
callsign_ssid: 1 # AX.25 SSID, matches the instance number
frequency_hz: 144390000 # example only, choose a local data frequency
digipeat_path: ["WIDE1-1"]
measurement: # section 12
retention_days: 400
rollup_after_days: 30
pskreporter: false
aggregator: null # station id of an opt-in group aggregator
share_grid_precision: 4
path_watch:
- peer: N9ABC-0
label: "County EOC"
expect_reachable_pct: 90
expect_bands: [40, 80]
degraded_snr_db: -18
alert_after_h: 12
net:
role: node # node | net_control | aggregator
report_to: null # net control station id, opt in
report_interval_ip_min: 5
report_interval_rf_min: 30
fleet:
coordinator: null # pinned identity, opt in
accept_classes: [channels, groups, peers, watched_paths, forms]
updates:
mode: security # off | security | full | manual
window_utc: "09:00-11:00"
days: [Tue, Wed, Thu]
stage_days: 3 # wait this long after a release before taking it
battery_floor_pct: 60
reboot_policy: window # never | window | required-only
defer_on_emergency: true
defer_on_priority_queue: true
offline_bundles_allowed: true
Visibility. The dashboard carries a "days since last successful update" indicator with the same weight as battery state and antenna health, because an unpatched node is a failure mode, and a failure mode that nobody can see is one that nobody fixes.
The design so far is node-centric, and emergency communications is organizational. Somebody is running the net, and that person needs to see all of it: what traffic exists, who is holding it, what is stuck, who is on the air, and what has actually been delivered to a human being.
Net control is a role, not a special build. Any node can be designated net control for a group and for an incident. It runs the same software with an additional screen.
The traffic board is the main view: every message the net control node knows about, in a sortable table showing precedence, age, originator, destination, current state, current custody holder, transport used so far, and time in current state. Rows turn amber past a precedence-dependent age threshold and red past a second one, which is how a stuck priority message announces itself rather than waiting to be noticed.
The station board shows every group member with last heard time by transport, queue depth by precedence, battery state, antenna health, and whether they are attended right now. A station that has gone quiet on every transport at once is highlighted, because that pattern is about the incident rather than about the radio.
How the data gets there. Group members send a periodic netstat profile message to the net control identity: their queue summary, custody list by message identifier, station health, and reachability summary. Signed, small, and over IP where possible. Over RF the same content compresses into one or two ~S frames, at a rate that respects the transmit budget: every 5 minutes over IP, every 30 minutes over RF, and immediately on any state change above routine precedence.
Consent and authority, stated explicitly. Reporting to a net control station is opt in, per group, and expires with the incident. And net control has no command authority over any node, which is the same invariant as 13.6: it can see, and it can send messages like anyone else. Traffic is assigned by sending someone a message asking them to handle it, not by reaching into their node. This is a deliberate refusal of a feature that every operational user will eventually ask for, and the reason is that a remote command channel into every node in a district is the single most dangerous thing this design could contain.
Output. The net control node produces the group-wide ICS-309 from 11.7, the after-action summary, and the delivery statistics by precedence and transport, for the whole net rather than for one station.
A district running forty nodes cannot configure them one browser session at a time, and cannot rely on forty volunteers to keep band plans, group memberships, form templates, and update windows aligned. The obvious solution, a management server that pushes configuration, is exactly the remote command channel that 13.6 forbids. The resolution is that the direction of control is inverted.
Policy is pulled, signed, and narrowly scoped.
| Class | Contents | Default |
|---|---|---|
channels |
Automatic and attended channel plans, band schedule | Enabled |
groups |
Group memberships and the group directory | Enabled |
peers |
Directory entries, peer keys, hub list | Enabled |
watched_paths |
The path watch expectations of 12.4 | Enabled |
forms |
Form template set and versions | Enabled |
updates |
Maintenance window, staging delay, reboot policy | Disabled by default |
blocklist |
Advisory blocklist entries | Disabled by default |
What a policy bundle can never contain: executable code, package sources, cryptographic identity material, credentials, transmit commands, or any instruction that causes an immediate transmission. Those are not in the schema, so a hostile or compromised coordinator can misconfigure a node's band plan and cannot make it do anything else. A local setting always overrides a policy setting, every application is written to the audit log with a diff, and one button reverts to local configuration.
Provisioning at scale. An enrollment token, presented as a QR code or a string, carries the coordinator identity, the group, the hub list, and the policy subscription. sudo hermes-setup --enroll <token> takes a node from a fresh image to a configured group member in one step, leaving only the call sign, instance number, and the administrator password to be entered locally. Identity keys are always generated on the node at first setup and never in an image, so a district can flash forty identical cards without ever creating two nodes that share an identity.
Health visibility. A fleet dashboard is simply a node with the net control role and a permanent group scope: nodes push health telemetry to it, opt in, and it displays. Read only, in the same way and for the same reason.
Everything in HERMES is one object type. A radiogram, an email, an ICS-213, a receipt, and a capability advertisement are all messages with different profiles.
{
"v": 1,
"id": "01J8Z4K7QW3M9XR2",
"from": "K9MEV-1",
"to": ["N9ABC-0"],
"profile": "radiogram",
"prec": "P",
"hlim": 4,
"created": "2026-08-10T14:32:07Z",
"expires": "2026-08-13T14:32:07Z",
"subject": "WELFARE CHECK OAK ST",
"body": "ARL SIXTY SIX WITH TRUCK X ALL WELL X",
"meta": {
"check": 9,
"place": "BATAVIA IL",
"filed": "1432Z AUG 10",
"signature_line": "MARK K9MEV",
"handling": "HXG"
},
"orig_class": "third_party",
"sig": "base64-ed25519-signature-over-canonical-form"
}
Identifier. A 16 character Crockford base 32 string derived from the first 80 bits of SHA-256 over the canonical serialization, with the top 48 bits replaced by a millisecond timestamp so that identifiers sort chronologically and collide with negligible probability. The result is content-addressed enough for deduplication and time-ordered enough for a queue.
Canonical form. JSON Canonicalization Scheme (RFC 8785), so that the same object signed on two nodes produces byte-identical input to the signature. This is unglamorous and absolutely load bearing: it is the difference between signatures that verify and signatures that mysteriously do not.
Precedence follows the ARRL and NTS convention: R routine, W welfare, P priority, E emergency. It drives queueing, transmit budget allocation, band selection aggressiveness, and retry intervals.
Hop limit (hlim) decrements at each custody transfer and prevents loops in combination with the path list carried in the custody record.
| Property | HMP/IP | HMP/RF |
|---|---|---|
| Underlying | TLS 1.3 over TCP, WebSocket framing | JS8 over SSB-suppressed FSK, via JS8Call |
| Throughput | Effectively unlimited | 40 to 90 characters per minute |
| Latency | Under 1 second | 15 seconds to several hours |
| Reliability | TCP plus application receipts | Selective repeat ARQ plus receipts |
| Signature carried | Full Ed25519, 64 bytes | 40 bit digest only |
| Framing | Complete JSON object | Fragmented, encoded, compressed |
| Addressing | Canonical CALL-N |
RF form CALL/N, or CALL |
The core hands the same object to either. The transports differ entirely in what they can afford.
Every automatic channel is placed inside a 97.221(b) segment with at least 1 kHz of margin at both ends, computed for JS8Call audio offsets in the range 200 to 2400 Hz.
| Band | Posture | Dial (USB) | Resulting RF | Inside segment | Use |
|---|---|---|---|---|---|
| 80 m | Automatic | 3.5860 | 3.5862 - 3.5884 | 3.585 - 3.600 | Night regional, NVIS, primary after dark |
| 40 m | Automatic | 7.1005 | 7.1007 - 7.1029 | 7.100 - 7.105 | Day and night regional, most reliable |
| 30 m | Automatic | 10.1405 | 10.1407 - 10.1429 | 10.140 - 10.150 | 24 hour workhorse, low QRM, no contests |
| 20 m | Automatic | 14.1015 | 14.1017 - 14.1039 | 14.1005 - 14.112 | Day medium haul, out of region |
| 60 m | Attended only | channelized | per 97.303(h) | not permitted | Excluded from automatic control |
| 80 m | Attended | 3.5780 | conventional JS8 | n/a | Interoperability with JS8Call community |
| 40 m | Attended | 7.0780 | conventional JS8 | n/a | Interoperability |
| 30 m | Attended | 10.1300 | conventional JS8 | n/a | Interoperability |
| 20 m | Attended | 14.0780 | conventional JS8 | n/a | Interoperability |
The 40 m automatic segment is only 5 kHz wide and is shared with every other automatically controlled digital station in the country. This is the most contended piece of spectrum in the plan, and the software mitigates by preferring 30 m whenever 30 m is open, which for regional Illinois paths is most of the time.
Band scheduling. A node in automatic posture spends its time according to a schedule derived from the links table, defaulting before any data is collected to: 80 m from 0200Z to 1100Z, 40 m from 1100Z to 1400Z and 2200Z to 0200Z, 30 m from 1400Z to 1700Z, 20 m from 1700Z to 2200Z. The schedule is advertised in the capability beacon so that a peer knows where to find the node and does not waste a transmission on a band the node is not monitoring. Within any 30 minute window the node also makes one 3 minute excursion to the next band in rotation to keep the database fresh, which is how the schedule improves itself.
A HERMES frame is carried inside a JS8Call directed message. The sigil ~ is chosen because it is not used by JS8Call protocol syntax and reads clearly in a monitor's window.
<TO>: <FROM> ~<T><flags> <fields...>
For example, a data fragment:
N9ABC: K9MEV ~D 7QK2 03/11 ARL SIXTY SIX WITH TRUCK X ALL W
Frame types:
T |
Name | Direction | Purpose |
|---|---|---|---|
H |
Hello | broadcast | Capability advertisement: version, bands, schedule, queue depth, hub reachability, key fingerprint prefix |
Q |
Query | directed | Do you have traffic for me, or for anyone I can reach |
O |
Offer | directed | I hold message <sid>, <n> fragments, precedence <p>, for <dest> |
A |
Accept | directed | Send it, starting at fragment <k> |
D |
Data | directed | Fragment <k> of <n> for session <sid> |
K |
Ack | directed | Fragments received, as a bitmap, plus a request for the missing ones |
R |
Receipt | directed | Custody accepted, or final delivery confirmed, with digest |
X |
Reject | directed | Refused, with a reason code (full, out of budget, wrong destination, unverified) |
I |
Identify | broadcast | Explicit station identification for 97.119 compliance |
S |
Status | directed | Queue and reachability state, sent in response to Q when there is no traffic |
Session identifier <sid> is four base 32 characters, derived from the message identifier, unique enough within the lifetime of an exchange and cheap enough to repeat in every fragment.
Fragmentation. The payload is encoded (9.10), then split into fragments sized to fill an integral number of JS8 frames. At JS8 Normal, a directed message carries roughly 20 characters of free text in the first frame after the addressing overhead, and roughly 20 in each subsequent frame. HERMES uses a fragment payload of 40 characters, which is two JS8 frames plus the fragment header, giving a natural unit of about 45 seconds. Larger fragments waste less overhead but retransmit more on failure; 40 characters is the point where the two curves cross at typical emergency-conditions error rates.
ARQ. Selective repeat with a window of six fragments. The sender transmits up to six, the receiver acknowledges with a 6 bit bitmap in a K frame, the sender retransmits only the gaps. Timeout is four frame periods plus the peer's advertised turnaround. Three consecutive failed windows abandons the session, records the failure against the link in the database, and returns the message to the router, which will try a different band, a different path, or a different transport.
Integrity. Each fragment carries a 10 bit CRC rendered as two base 32 characters. The reassembled payload carries a 40 bit SHA-256 prefix digest, rendered as eight base 32 characters, which is checked against the digest in the O frame. Both algorithms are public and neither carries a key.
Speed selection. Normal (50 Hz, 15 s, about -24 dB threshold) is the default. Slow (25 Hz, 30 s, about -28 dB) is selected automatically when the link table shows a mean SNR below -18 dB for that peer on that band, and for the very long paths where the extra 4 dB is the difference between a message and nothing. Fast is used only in attended mode. JS8 40 and JS8 60 are never used, because they do not participate in heartbeat networking.
Two mechanisms operate together, and the choice to use the native one for the common case is deliberate.
Native JS8Call heartbeat. The node participates in ordinary JS8 heartbeat networking, transmitting HB in the heartbeat sub-band on a configurable interval (default 30 minutes for a compound call sign, 15 minutes for a bare call sign, 60 minutes in power saving posture, per the arithmetic in 5.2) and answering heartbeats it hears with SNR reports. This costs one frame, is understood by every JS8Call station in the world, and populates RX.CALL_ACTIVITY, which hermes-rfd polls every 60 seconds and writes into observations. A HERMES node therefore appears to the wider JS8Call community as a well-behaved ordinary station, and a plain JS8Call operator can see it, call it, and leave it a message without knowing HERMES exists.
HERMES capability beacon. A ~H frame to the group @HERM, at a much lower rate (default 120 minutes for a compound call sign, 60 minutes for a bare one, or immediately after a band change), carrying what the heartbeat cannot: protocol version, enabled bands, duty schedule, current queue depth by precedence, whether the node currently has internet, and the first 20 bits of its key fingerprint. Example:
@HERM: K9MEV/1 ~H 1 1110 SCH2 Q0102 NET1 F7K2A
decoded as version 1, bands 80/40/30/20 enabled as a bitmask, schedule profile 2, queue of 0 emergency, 1 priority, 0 welfare, 2 routine, internet available, fingerprint prefix F7K2A. With a compound call sign that is two JS8 frames at Normal speed, 30 seconds, every two hours, which is 15 seconds per hour of channel occupancy per station. A hundred stations in a region cost 25 minutes of aggregate channel time per hour, which is why the interval is measured in hours rather than minutes.
Listening is continuous and free. Every decode of any kind, HERMES or not, is written to observations. A node that has been running for a week in Batavia knows which of its neighbors are audible on which band at which hour, and so does every other node, and that shared knowledge is what makes the routing in 9.6 possible without any central coordination.
The router runs whenever a message enters the queue, whenever a receipt arrives, whenever presence data changes materially, and on a 60 second tick. For a message m with destination d, it enumerates candidate paths and scores them.
score(path) = W_transport
+ W_directness
+ W_quality
+ W_freshness
- W_cost
- W_hops
with the following weights, all configurable:
| Term | Value | Rationale |
|---|---|---|
W_transport |
+1000 IP direct, +900 IP via hub, +400 VHF direct, +300 VHF digipeated, +100 HF direct, +60 HF relayed | The 500 point gap makes the internet preference decisive rather than advisory, which is the stated requirement, and VHF sits decisively above HF for the same reason: it is two orders of magnitude faster and legally simpler |
W_directness |
+50 if the destination itself, +0 if a relay | |
W_quality |
40 + SNR for the relevant band and hour, clamped to 0 to 60 |
A -20 dB path scores 20, a 0 dB path scores 40 |
W_freshness |
+30 if heard in the last hour, +15 within 6 hours, +5 within 48 hours, 0 beyond | |
W_cost |
estimated frames to send, times 2 | Long messages prefer better paths |
W_hops |
25 per hop |
The highest scoring path is attempted. On failure, the path is penalized in the link table and the router re-runs, which produces automatic, memory-bearing fallback without any explicit state machine.
The internet preference in practice. Any live IP path outscores any RF path by at least 800 points, so RF is used only when no IP path exists or every IP path has failed. That is the requirement. But there is a subtlety worth stating: hermes-netd must distinguish "the internet is down" from "this peer is down". It probes three independent anchors (the configured hubs, a well-known public resolver, and the last-known-good direct peer) and only declares internet loss when all three fail, which prevents a single hub outage from pushing a region onto HF unnecessarily.
Scheduled delivery. If the best path is a peer that is asleep under a duty schedule, the router does not burn transmissions calling into the void. It schedules the attempt for the peer's next advertised window and displays that time in the UI, so the operator sees "next attempt 0215Z via N9ABC on 80 m" rather than an unexplained pause.
Relay selection. A relay candidate must have advertised the relay capability, must have been heard within the freshness window, and must itself have a scored path to the destination that it has advertised in a ~S frame or that is inferable from the local database. Two hops is the default limit for automatic relaying, extendable to four for emergency precedence. Every relay decision is logged with its reasoning, and the message timeline in the UI shows why each hop was chosen, because an emergency communications system whose routing cannot be explained after the fact is a liability during the after-action review.
HERMES uses explicit custody transfer, borrowed from delay tolerant networking, rather than best-effort forwarding.
m for destination D and finds a path through relay B.~O offering m. B evaluates against its storage quota, its transmit budget, its policy (does it relay for this precedence, this origin class), and its own path to D.~A, A transmits fragments, B verifies the digest.~R with custody acceptance. At this moment responsibility transfers. A marks the message as custody_transferred, stops attempting delivery, but retains the message and the custody record for the message lifetime in case B never delivers.Custody timeout. Each custody record carries a deadline derived from the message expiry and the hop limit. If A does not see an end to end receipt before the deadline, A resumes responsibility and re-routes, avoiding B. This means a relay that is destroyed, powered down, or simply out of range does not silently swallow traffic, which is the classic failure of naive store and forward systems.
Storage quotas. Default 200 messages or 2 MB of relayed traffic, whichever comes first, with emergency and priority traffic exempt from the quota up to a hard ceiling. Full storage produces ~X with reason FULL, which the offering node records so it does not keep asking.
Four levels of proof, and the user interface distinguishes all four rather than showing a single checkmark.
| Level | Name | Meaning | Cryptographic strength |
|---|---|---|---|
| 1 | Transmitted | The frames left this station | None, local log only |
| 2 | Received by next hop | ARQ completed and digest matched at the neighbor | 40 bit digest, CRC per fragment |
| 3 | Delivered | The destination has the message and issued a receipt | 40 bit digest over RF, full signature over IP |
| 4 | Verified | A full Ed25519 receipt from the destination's pinned key is held | 128 bit security |
Receipt upgrade is the mechanism that makes level 4 achievable in a network where RF cannot carry a signature. When D receives a message over RF, it issues a compact ~R receipt containing the message identifier and the 40 bit digest. It simultaneously queues a full signed receipt object. The moment D has any IP path (its own, or a relay's), the full receipt flows to the originator, and the originator's UI transitions the message from Delivered to Verified. If IP never returns, the compact receipt stands, and the UI is honest about what it does and does not prove.
Non-repudiation is bounded and stated as such. A 40 bit digest proves the destination saw the exact bytes. It does not prove which station issued the receipt, because RF frames carry no signature. An adversary with a transmitter could forge a compact receipt. The UI therefore never claims Verified on RF-only evidence, and for traffic where proof of delivery genuinely matters (a served agency resource request), the operator can mark the message require_verified, which keeps the message active and retrying until a full signed receipt is obtained.
A HERMES node must be a good neighbor and a useful one to operators who have never heard of it.
SNR?, GRID?, STATUS?, and HEARING? queries with sensible values.MSG store-and-forward inbox traffic and surfaces it in the HERMES inbox, so a plain JS8Call user can leave a message for a HERMES station using nothing but stock software.> relay syntax when asked, subject to the same budget and policy controls.~H frames go to a group, which plain stations can subscribe to or ignore.This is not a courtesy. It is a survival property: a network of six HERMES nodes is nearly useless, and a network of six HERMES nodes embedded in a regional JS8Call population of two hundred is a working system on day one.
JS8 encodes text with a variable-length code averaging about 3.4 bits per character over its efficient alphabet, packing roughly 69 bits of payload into each frame. Characters outside that alphabet cost dramatically more or cannot be transmitted at all. Every RF payload is therefore confined to A-Z, 0-9, space, and the punctuation . , ? / - +.
Revision 1.2 got the layering wrong here, and the correction matters. It described abbreviation and ARL numbered text substitution as encoding steps performed by the transport. They cannot be. Those transformations change the message body, and the body is signed. Anything applied to it after signing breaks every signature over it, and a message whose signature fails is worse than no message at all.
The correct arrangement, and the one implemented:
Composition aids, applied before the message is created and signed. The composer folds text into the wire alphabet, applies the ARRL radiogram punctuation convention, suggests ARL numbered texts, and offers the published abbreviation table. The operator sees the result, that result is what gets signed, and that is what goes on the air. There is no transformation in between.
X, other characters are spelled out. Not compression, an existing standard operators already know.ARL SIXTY SIX is a complete standard sentence in thirteen characters, the largest single win available and requiring no invention. The composer suggests them as the operator types and shows the saving.The transport refuses rather than transforms. A body containing characters JS8 cannot carry is rejected at the point of transmission with a message naming the offending characters and saying where to fix it. That is a worse user experience than silently cleaning it up, and it is the only correct behavior: the alternative is a message that arrives with a signature that does not verify.
Binary fields use Crockford base 32, which is cheap in the JS8 alphabet and excludes the ambiguous letters so a fingerprint survives being read aloud on a repeater.
There is deliberately no general-purpose binary compression. A DEFLATE stream base 32 encoded expands by 60 percent before it compresses, which for messages under 300 characters is a net loss, and it would raise a question under 97.113(a)(4) that a published substitution table does not.
Unattended stations that transmit without restraint are how automatic digital operation gets a bad name, and 40 m ACDS spectrum is 5 kHz wide. Every node enforces a budget:
hermes-rfd consults the JS8Call band activity window and defers if the intended offset is occupied, hunting for a clear offset in 100 Hz steps.The budget is visible on the Radio screen as a consumed-versus-available bar, because an operator who can see the budget will manage it, and an operator who cannot will assume it does not exist.
JS8Call group names are a flat, uncoordinated, first-come namespace. @ALLCALL, @HB, and a handful of others are reserved by the software; everything else is whatever a local community decided to type. There is no registry with any authority behind it.
Decision: accept collisions. Do not claim a prefix.
The alternative, prefixing every HERMES group (@H-FRRL rather than @FRRL), buys uniqueness at the cost of the property that section 9.9 argues is the system's best chance of being useful, which is that a plain JS8Call operator monitoring their club's group should see HERMES stations in it and be able to talk to them. A parallel prefixed namespace makes HERMES a private club inside a public band, which is both antisocial and self-defeating in a network whose whole problem is having too few participants.
Collisions are benign here, and the reason is that HERMES frames are self-identifying:
~ sigil disambiguates without a namespace. A HERMES protocol frame is unmistakably a HERMES protocol frame. A human reading the group window sees a short machine string and ignores it, the same way they already ignore heartbeats and SNR reports.~ group traffic is never parsed as protocol. Plain text arriving in a shared group is surfaced in the HERMES inbox as an ordinary message from an ordinary station, which is exactly right.~ frames to a group only after it has heard at least one member of that group advertise HERMES capability. A club that has never heard of this project never sees a byte of it in their group window, which is the courtesy that makes accepting collisions defensible.The rule is enforced, not merely stated. A node sends protocol frames to a group only after it has heard a HERMES-capable station in that group within the last thirty days. Revision 1.2 wrote this promise down and the first implementation beaconed unconditionally, which would have made the whole position dishonest.
The awkward case is the first node in a region, which by definition has heard nobody, and refusing forever would mean the network can never start. So there is a deliberate operator override, hermesctl group enable @HERM, which states the consequence at the point of use ("this puts machine traffic into a group that may belong to someone else") and records who enabled it and why. Correct for the first node in a county; discourteous otherwise, and now attributable either way.
One reservation, and it needs checking. @HERM is used as the control group for capability beacons. That is a claim on a name in a namespace where claims have no force, so before release the name must be checked against actual on-air usage and against the JS8Call community's informal lists. If it is already in use by someone else, the project picks a different one and moves on. This is an open item in section 20.
Group names are 2 to 8 uppercase characters, and two-character names are avoided because several short names are reserved or conventional in JS8Call.
The stated requirement is "limited centralized infrastructure". The interpretation adopted here is that the central component must be small enough to run on a 5 USD per month virtual machine, replaceable by any operator in an afternoon, federated so that no single instance is authoritative, and unnecessary once two stations have met.
Ownership is left to whoever deploys. This project operates no hub, ships with no hub configured, and names no default. A freshly installed node works out of the box with no hub at all: it talks to peers directly where it can, over RF where it cannot, and asks the operator to add a hub only when they want the convenience of rendezvous and mailbox. That is a deliberate choice about power as much as about architecture. A project-operated default hub becomes a chokepoint, a dependency, a privacy question, and eventually somebody's unpaid job.
The recommended pattern for an operator or a club who wants one is in 10.7: run the hub on the node itself, on 44Net address space.
It does not route, it does not make decisions about transports, it does not hold long-term message archives, it does not sign as a station, it does not touch RF, and it does not have any privileged view that a participating node lacks. A hub operator who turns hostile can deny service and can read traffic, and traffic is not confidential anyway.
A single Python module behind a reverse proxy, roughly 400 lines including the quota logic. Endpoints: /register, /mailbox/{station}, /mailbox/{station}/collect, /directory, /health, /stats. Station authentication is by signed challenge against the station's Ed25519 key, which is stronger than a password and needs no account system.
Deviation from earlier revisions: REST and polling, not a WebSocket. A WebSocket remains the right answer for a busy hub and can be added without changing a node. It is not what was built, for three reasons. Polling survives every corporate proxy, captive portal, and flaky link this system exists for. A mailbox checked every thirty seconds is indistinguishable from a push for traffic that took nine minutes to cross forty metres. And a polling client has no reconnection state machine to get wrong, which on an appliance that must run unattended for months is worth more than the latency it costs.
The challenge is client generated, not server issued. A server nonce needs server state, an extra round trip, and a cleanup job. A client nonce, a bounded timestamp, and a replay cache give the same guarantee in one request. The cost is that both ends need a roughly correct clock, which a JS8 node has anyway because JS8 does not work without one. The token binds the station, the action, and a digest of the request body, so a captured token cannot be replayed to push different content.
Federation is deliberately simple: each hub holds a list of peer hubs, pushes directory deltas to them every 5 minutes, and forwards mailbox deposits for stations it does not know to the hub that claims them. There is no consensus protocol and no attempt at global consistency, because the failure mode of inconsistency (a message takes the RF path instead of the IP path) is benign.
Self-hosting is the intended default for any organized group. A club, an ARES district, or an individual operator runs a hub for their own region. The reference deployment is a container plus a Caddy configuration, and the documentation includes an AMPRNet variant with the hub reachable on 44.72.0.0/16 space, which gives an ARDC-allocated, globally routed, non-commercial path that is entirely independent of any consumer ISP. For a network whose premise is infrastructure failure, having the hub reachable over an amateur-operated network rather than only over a commercial one is a meaningful additional layer.
A hub is run by a volunteer on hardware that does something else too, and until
recently its operator could see /stats and journald and nothing more. They
could not list registrations, remove a stale one, issue an invite, or block an
abusive station without editing SQLite by hand.
The operator interface is a small set of authenticated endpoints: list stations with their mailbox depth, block or unblock, remove a registration, issue and list invite codes, manage federation peers, and read the action log. Authentication is a single bearer token rather than an account system, because the hub has exactly one operator and an account system here would be more code to get wrong than the thing it protects. With no token configured the interface is disabled, not open.
Removing a registration is deliberately manual and deliberately explicit. The hub refuses a key change on purpose (10.2); undoing that refusal should be a person's decision after they have confirmed the change out of band, and the response says so.
Federation is a push rather than a pull: each hub sends its directory to its configured peers on a timer. Push, because a hub behind NAT can reach its peers even when they cannot reach it. Two constraints keep it safe. A hub accepts federation only from peers it has been configured with, so the directory is not writable by the internet. And a federated entry never overwrites a key already held: a hub that has been taken over must not be able to tell its peers that a station's key has changed. That is what rotation certificates are for, and they are verified by the node rather than by any hub.
Most amateurs sit behind a consumer internet connection with dynamic addressing, carrier grade NAT, or both, which means no inbound reachability, which is the entire reason a rendezvous service exists. There is a way out of that specific to this hobby, and an operator who already runs 44Net Connect has it available today.
The idea. Take a small block of 44Net (AMPRNet) address space, carry it home over WireGuard tunnels to the 44Net Connect points of presence, announce it with BGP from a small router, and put the node on it. The node then has a real, globally routed, static, publicly reachable IPv4 address that has nothing to do with the local ISP. Peers connect to it directly. If it also runs hermes-hub, other operators can use it as their rendezvous service.
Why this fits the design's premises unusually well:
The honest caveats:
IPv6 is the simpler alternative for anyone who does not want to run BGP. Most residential connections now carry a routable IPv6 prefix, most CGNAT deployments do not apply to IPv6, and a node with a stable IPv6 address and a properly restrictive firewall solves the same reachability problem with a tenth of the work. The design prefers IPv6 endpoints where both peers have them. The same warning applies: a globally routable address is a globally reachable address.
A hub is a public service with a listening socket, an account model, and a store of other people's messages, run by a volunteer, usually on hardware that does something else too. It is the most attractive target in the system and the piece most likely to be neglected. The hardening requirements for it are specified in 13.6, and they are requirements rather than suggestions: the reference implementation ships with them applied, refuses to start with a default configuration, and logs a prominent warning if the operator disables any of them.
The native profile. The composer implements the full ARRL form: number, precedence, handling instructions (HXA through HXG), station of origin, check, place of origin, time filed, date, address block, text, and signature.
ARL SIXTY SIX, and the recipient reads plain English.Subject plus body, with a soft limit of 600 characters and a hard limit of 2000.
The composer shows two numbers, because they answer different questions.
Airtime is what the encoder costs: frames times slot time, the arithmetic of section 15. It is the cost to everyone else on the channel.
Time to a person is what an emergency coordinator actually asks. It adds the queue ahead of this message at this precedence, the transport the router will choose, and the interval between a message reaching the destination node and an operator there telephoning the addressee. It comes from this node's own measured history with this peer, labelled measured when there are ten or more samples, few samples between three and ten, and no history when the node is falling back to arithmetic. Frequently it is an order of magnitude larger than the airtime, and it is the honest answer.
The counter is the feature that teaches brevity better than any training document, and giving the second number is what stops an operator quoting the first one to somebody who then expects the message to have arrived.
Airtime is also a limit, not only a warning. Nothing previously stopped an operator queueing a 2000 character message for HF, which is roughly fifty minutes of transmission on a segment five kilohertz wide shared nationally, several times the entire hourly budget. Composition now refuses above a configurable ceiling, defaulting to fifteen minutes of airtime and forty five for emergency precedence, with the refusal naming the ARL numbered text or abbreviation that would shorten it. The limit applies only when the message would actually go over RF: length costs nobody anything over the internet, and a node with an IP path to the destination is not restricted.
Addresses may be HERMES station identifiers or, through the !EMAIL service address, ordinary internet email delivered by a gateway node that has connectivity. Inbound email to callsign@hermes.example.org is accepted, stripped to plain text, truncated with an explicit marker, and injected as a message. HTML, attachments, and quoted reply chains are discarded rather than truncated, and the sender receives a bounce explaining why.
The standard general message form: to, from, subject, date, time, message, approved by, reply. Field-for-field, so that a served agency receives something their people already recognize, and so that a message can be handed to an emergency manager without translation. Renders to a printable ICS-213 PDF on receipt.
A compact profile carrying grid, battery state, queue depth, and a short status line, used for automated check-in nets. This is what makes the system useful on an ordinary Tuesday: a weekly automated check-in where twelve nodes report in and the net control station sees a table rather than taking roll for twenty minutes. Optionally bridged to APRS through the !APRS service address, which is a natural fit given an existing igate.
Three profiles are not enough for a served agency, and this is the gap that most often decides whether an emergency coordinator pilots a system or politely declines. Winlink's real advantage over every competitor is not its modem, it is that an emergency manager can ask for an ICS-213RR and get one.
HERMES therefore carries a form template engine rather than a fixed set of hard coded profiles.
A template is a signed YAML document declaring fields, types, validation, the printable layout, and the wire encoding:
form: ICS-213RR
version: 3
title: Resource Request
fields:
- {id: incident, label: Incident Name, type: text, max: 40, required: true}
- {id: date_req, label: Date/Time Prepared, type: datetime, required: true}
- {id: qty, label: Qty, type: int, repeat: 8}
- {id: kind, label: Kind/Type, type: text, max: 30, repeat: 8}
- {id: descr, label: Detailed Description, type: text, max: 80, repeat: 8}
- {id: arrival, label: Requested Date/Time, type: datetime}
- {id: deliver, label: Deliver To, type: text, max: 40}
- {id: priority, label: Priority, type: enum, values: [Urgent, Routine, Low]}
- {id: approved, label: Approved By, type: text, max: 40}
wire:
order: [incident, date_req, priority, qty, kind, descr, arrival, deliver, approved]
separator: "|"
render: ics213rr.html
Design consequences worth stating:
The shipped set at release: ICS-213 (general message), ICS-213RR (resource request), ICS-205 (communications plan), ICS-214 (activity log), ICS-309 (communications log, generated rather than composed, see 11.7), the ARRL radiogram, and a welfare inquiry profile. That is the minimum credible set. A club or district adds its own without touching code.
Winlink is the incumbent, it is what served agencies and most ARES groups already know, and a system that cannot exchange traffic with it is a system that operates in a corner by itself. Interoperating is worth more than competing.
The bridge is a service address, !WL, implemented on any node that has both a HERMES identity and Winlink connectivity. Outbound, a HERMES message addressed to !WL/W1AW or !WL/name@example.org is rendered into a Winlink message and submitted through the gateway node's own Winlink client session. Inbound, mail arriving at the gateway operator's Winlink address with a HERMES address on the first line is injected into the network.
The honest constraints, which belong in the document rather than in a support forum later:
Every activation ends with paperwork, and the ICS-309 Communications Log is the piece an agency actually asks for. The node already records every message, every transmission, every receipt, and every timestamp, so producing an ICS-309 costs nothing beyond the rendering.
GET /api/v1/reports/ics309?from=&to=&incident= produces a completed form as CSV and as a print-ready HTML view. Deviation: not server-side PDF. A PDF renderer is a heavy dependency on a Raspberry Pi, and a browser prints the HTML to PDF in one click; a pdf extra can add server-side generation later for anyone who wants it. The content is incident name, operational period, radio network, station identifier, operator, and then the chronological log of time, from, to, and subject for every message handled. The System screen has a button. This is the single highest-value-per-line-of-code feature in the whole design, because it converts an after-action chore that normally takes an evening into a file, and because it is the artifact that makes an emergency coordinator take the rest of it seriously.
The same engine produces an ICS-214 activity log for the operator and an after-action summary.
The summary carries delivery rates by precedence and by transport, and the two elapsed times that matter: from composition to the destination node, and from composition to a person being told. Median, ninetieth percentile, fastest and slowest, per precedence. The gap between those two figures is usually the finding: six minutes to cross a county and forty one until anyone was told is a difference made of operators rather than radios, and no amount of protocol work shortens it. Reporting only the first number, as most systems do, quietly hides the part an emergency coordinator is asking about. Duplicate final deliveries (11.8) are listed too.
A radiogram is not delivered when a computer receives it. It is delivered when a human being is told what it says. Revision 1.1 tracked custody all the way to the destination node and then stopped, one hop short of reality.
The message state machine therefore extends past delivered:
| State | Meaning |
|---|---|
delivered |
The destination node holds the message and issued a receipt |
assigned |
An operator at the destination has taken responsibility for final delivery |
served |
An operator asserted final delivery to the addressee, with method, time, and who received it |
undeliverable |
Final delivery attempted and failed, with a reason |
Final delivery is an operator assertion, captured in the interface in about fifteen seconds: method (telephone, in person, email, hand carried, left with a third party), time, the name of the person who received it, and free text. It is signed by the delivering node and propagated back to the originator as a service message, which is exactly how NTS has always closed the loop, and it appears on the originator's message timeline as the final entry.
undeliverable produces a service message too, carrying the reason, because "we tried three times and the phone is dead" is itself information the originator needs, and an emergency network that silently swallows failures is worse than one that fails loudly.
Delivering the same message to the same person twice is caught. If two nodes both hold a message for one addressee, which happens with group traffic and with a relay that is also local, two operators may both make the call. The recipient hears the same thing twice from two different hams, which reads as a shambles. Recording a delivery therefore checks what has already been recorded: the same recipient is refused with the previous operator, time, and method named, and a different recipient warns and proceeds. It is advisory rather than absolute, because two people genuinely may need telling, and a system that blocks a legitimate second delivery is more annoying than one that asks. Duplicates that do occur appear on the after-action report.
Delivery assertions and their timestamps flow into the ICS-309 in 11.7, which means the log an agency receives shows when a message reached a person rather than when it reached a machine.
A messaging network is worth nothing until it has peers. A measurement network is worth something on the first day, with one node, and its value grows monotonically from there. That asymmetry is the most important practical fact about this design, and revision 1.1 buried it in a data model section.
Every HERMES node is a continuously operating, unattended, calibrated HF path measurement instrument that happens to also pass traffic. It records what it hears, from whom, on what band, at what hour, at what signal to noise ratio, against what noise floor, with a disciplined clock and a known antenna. It records what it transmitted and whether that transmission succeeded. With a receive-only SDR fitted (6.9) it does this on four bands at once, continuously, forever.
The distinction that matters is between prediction and measurement. VOACAP will tell an emergency coordinator what should work between two grid squares. It will not tell them that the county EOC's antenna has been detuned since the ice storm, that a new solar installation raised the noise floor at the shelter by 9 dB last March, or that the 40 m path to the hospital closes ninety minutes earlier in November than the model suggests. A network of nodes that have been listening to each other for a year knows all three, as fact rather than as forecast.
Five independent streams, four of which cost nothing beyond what the node is doing anyway.
| Stream | Source | Cost | What it answers |
|---|---|---|---|
| Decodes | Every JS8 decode, HERMES or not, on every monitored band | Free | Who can I hear, when, how well |
| Reverse spots | SNR reports other stations return to our heartbeats, plus PSKReporter query when online | Free | Who can hear me, which is a different question |
| Exchange outcomes | Every attempted transfer, its result, retries, and elapsed time | Free | Does the path carry traffic, not merely a beacon |
| Noise floor | S meter sampled per band per hour with no signal present, plus wideband SDR floor | Free | Why a path degraded, and whether the cause is local |
| Station health | SWR per band, power out, supply voltage, temperature, uptime | Free | Whether a measurement can be trusted at all |
Asymmetry is measured explicitly and is not an edge case. Hearing a station is not the same as being heard by it, and on a network of 5 W stations with mismatched antennas and mismatched noise floors the two diverge constantly. The database stores both directions separately and the router uses the outbound direction, which is the one that determines whether a message will actually arrive.
Every measurement carries its own provenance: which radio, which antenna profile, which firmware, whether the SDR or the transceiver produced it, and whether the node's clock was disciplined at the time. A measurement whose clock was free running is retained and flagged, never silently mixed with good data.
| Derived product | Definition | Used by |
|---|---|---|
| Link quality | Composite of decode rate, mean and best SNR, and exchange success, per peer, band, hour, and month | The router, continuously |
| Path availability | Percentage of hours in a period in which a peer was reachable at all, by band | Planning, and the reachability report |
| Opening and closing times | First and last reliable decode per band per peer, tracked as a distribution rather than a single time, with seasonal drift | Net scheduling |
| Band ranking by hour | Which band to try first, right now, for this specific peer | The router, and the operator's band button |
| Noise floor trend | Per band, per hour, with change point detection | RFI hunting, and explaining a degraded path |
| Coverage gaps | Peers or areas that no node in a group can reach at any hour on any band | Deciding where a relay node needs to exist |
| Prediction error | Measured SNR minus VOACAP or ITU predicted SNR for the same path and hour | Anyone who cares whether the models are right, which is a real research community |
This is the feature that converts a database into an operational tool, and it is borrowed wholesale from network monitoring practice.
An operator designates certain paths as watched: the county EOC, the hospital, the district's two backbone relays. For each, they set an expectation:
path_watch:
- peer: N9ABC-0
label: "Kane County EOC"
expect_reachable_pct: 90 # over any rolling 7 days
expect_bands: [40, 80]
expect_hours_utc: [0000-0400, 1200-1600]
degraded_snr_db: -18
alert_after_h: 12
The node then continuously evaluates whether that expectation is being met and raises an alarm when it is not. "We have not been able to reach the EOC on any band for fourteen hours" is a finding that arrives on a Tuesday in April rather than a discovery made during an activation in July. The alarm goes to the dashboard, and out by email or to a group over the network if configured.
Each watched path gets a monthly one-page report: availability by band and hour, mean and worst SNR, the number of messages exchanged, the number of failures, and the trend against the previous three months. That page is a communications plan's evidence base, and it is exactly the artifact that turns "amateur radio can reach the shelter" from an assertion into a claim with numbers behind it.
Local first. Everything is queryable from the node with no external service: the API in appendix C, CSV and JSON export, and ADIF export so decodes can go into a normal logging program.
Community contribution, opt in. A node with connectivity can report spots to PSKReporter, which costs nothing, benefits the wider propagation research community, and gives the operator a second view of their own reverse path for free. WSPR reception through the SDR chain is the same idea with better science behind it.
Group aggregation, opt in, signed, and coarse. A club or district can nominate an aggregator that collects the link tables (not the messages, not the raw decode text) from consenting nodes and produces the regional picture: a matrix of who can reach whom, by band and hour, for the whole group. This is what makes coverage gap analysis possible, and it is the thing an emergency coordinator will actually want on a wall. Aggregation is off by default, is per-node consent, and carries the same "we see everything you send us" honesty as the hub.
Privacy, in a hobby where call signs are public and addresses are in a federal database anyway. Position is shared at the precision the operator chooses, defaulting to a four character grid square rather than a six character one for aggregation, and a node can participate in measurement while suppressing its own position entirely. Nothing about message content is ever included in a propagation export.
Observations are the largest table and the most valuable one, so it is sized deliberately rather than by accident.
| Node type | Decodes per day | Raw storage per year | After rollup |
|---|---|---|---|
| Single band, quiet area | 2,000 to 5,000 | 60 to 150 MB | 15 MB |
| Single band, 40 m evening | 15,000 to 30,000 | 400 to 800 MB | 40 MB |
| Four bands with SDR | 50,000 to 120,000 | 1.5 to 3.5 GB | 120 MB |
Retention defaults to 400 days of full resolution, which covers a complete seasonal cycle plus a margin for year over year comparison, followed by hourly rollups kept indefinitely. A 128 GB card holds decades of rollups and several years of raw data. The rollup is incremental and runs nightly, and the raw table is never deleted without the rollup having succeeded first.
The dataset is the asset. The backup in 8.5 includes it, the export is documented, and the schema is published, because a decade of continuous multi-band regional reachability measurements from fixed stations with known antennas is a genuinely rare thing, and it should outlive both the software and the operator's interest in the software.
The whitepaper has so far described a database. This is what it is for.
Before an event, choosing a communications plan. A district writes an ICS-205 that says the tactical net meets on 40 m at 0100Z and the alternate is 80 m. Today that choice is made from a propagation model, a rule of thumb, and whatever worked once in 2019. With a year of measurement across the actual stations that will be on the net, the choice is made from a table showing that 40 m at 0100Z reached seven of eight stations 94 percent of the time last winter, and that the eighth needs 80 m. The plan stops being a guess.
During an event, routing. The router picks the band and the relay from what is being heard right now rather than from a schedule. This is the use described in 9.6 and it is the least interesting of the seven.
During an event, situational awareness for net control. A net control station looking at the group matrix sees immediately that three stations in the northwest of the county have gone quiet on all bands simultaneously, which is a power or a weather event rather than a propagation event, and which is actionable intelligence about the incident and not merely about the radio network.
After an event, the after-action report. Delivery rates by precedence and transport, which paths carried the load, which failed, and how long messages actually took. Attached to the ICS-309 from 11.7, this is a communications section that says something.
Between events, finding the network's holes. Coverage gap analysis answers the question every ARES group struggles with: where does the next node need to be, and does it need a better antenna or just a better location. A measured answer changes how a limited equipment budget is spent.
At home, hunting your own noise. The noise floor trend with change point detection will tell an operator the exact day their neighbor's new solar inverter came online, and the band and hour signature will usually tell them what class of device it is. This is the use case that will get people to install a node who do not care about emergency communications at all, and that is not a joke: a node that is useful on ordinary days is a node that is still running when the extraordinary day arrives.
For the propagation community, measurement against prediction. Fixed stations, known power, known antennas, disciplined clocks, continuous operation, multi-band, multi-year, with the raw data exportable. Comparing that against VOACAP and against the current solar indices is real work that people are already doing with WSPR and PSKReporter data, and a HERMES network contributes to it as a side effect of existing.
With one node, the operator gets a noise and reachability record of their own station and contributes spots to PSKReporter. Useful on day one.
With three to five nodes in a club, the group gets a real matrix of who can reach whom, which bands work when, and an evidence base for a communications plan. Useful within a month, and useful without a single emergency message ever being sent.
With fifteen to forty nodes across a district, the group gets coverage analysis, watched path alarms on the links that matter, and a messaging capability that is exercised continuously rather than assumed.
The messaging capability is what the system is for. The measurement capability is why anyone would run it long enough for the messaging to matter. Both belong in the first paragraph of any description of this project, and the ordering above is the ordering in which the value actually arrives.
Every listening socket in the system, its default binding, and what protects it.
| Service | Port | Default bind | Exposure | Authentication |
|---|---|---|---|---|
| Web UI and REST | 443/tcp | LAN and WireGuard interfaces only | Public exposure is opt-in, with a warning and a confirmation | Password (argon2id) plus optional TOTP, session cookie, CSRF token |
| Peer transport | 8443/tcp | All interfaces, but only when enabled | Public by design | TLS 1.3 plus Ed25519 signed challenge, both directions |
| AP mode UI | 443/tcp on 192.168.44.1 | Only while the AP is active | Local RF range | Same as web UI, plus a WPA2 passphrase generated at setup |
| JS8Call API | 2442/tcp | 127.0.0.1 only | None | None needed; loopback only, enforced in the JS8Call config and by nftables |
| Scan decoders | 2443+/tcp | 127.0.0.1 only | None | As above |
| SMTP submission | 2525/tcp | 127.0.0.1 only | None | Off by default, enabled explicitly |
| SSH | 22/tcp | LAN and WireGuard only | Operator choice | Public key only, passwords disabled, root login disabled |
hermes-hub |
443/tcp | All interfaces | Public by design | Signed challenge, optional invite or attestation |
Anything not in this table does not listen. hermes-core, hermes-rfd, hermes-radiod, and hermes-scand communicate only over a Unix domain socket with filesystem permissions, and have no TCP listener at all.
Shipped applied, not documented as an exercise for the operator.
Network.
nftables with a default-deny input policy, an explicit allowlist per the table above, connection rate limiting per source address, a per-source concurrent connection cap, and SYN flood protection. ICMP and ICMPv6 are permitted, because breaking path MTU discovery to look tough is a self-inflicted wound.IPAddressAllow and IPAddressDeny on the systemd units confine hermes-netd to the configured hubs and peers.Operating system.
PermitRootLogin no, PasswordAuthentication no, AllowUsers limited, sshguard or fail2ban fitted. The port is left at 22, because moving it is not a security control and it breaks things.Service isolation. Each service runs as its own unprivileged user with no shell and no sudo, under systemd sandboxing:
[Service] User=hermes-web NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true PrivateDevices=true ProtectKernelTunables=true ProtectKernelModules=true ProtectKernelLogs=true ProtectControlGroups=true ProtectClock=true ProtectProc=invisible RestrictNamespaces=true RestrictRealtime=true RestrictSUIDSGID=true RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX LockPersonality=true MemoryDenyWriteExecute=true SystemCallFilter=@system-service SystemCallArchitectures=native CapabilityBoundingSet= UMask=0077 ReadWritePaths=/var/lib/hermes/web
hermes-radiod is the only unit with DeviceAllow for the serial port, and it is scoped to that one device node. PrivateDevices is relaxed only there.
Web application.
script-src 'self', no inline script and no inline event handlers, which the progressive web app is built to satisfy rather than worked around. X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, a restrictive Permissions-Policy, and cross-origin isolation headers.HttpOnly, Secure, SameSite=Strict. CSRF tokens on every mutating request. No CORS, at all.innerHTML. A message body arriving over the air is hostile input by default.Secrets. The identity private key is mode 0600, owned by hermes-core, outside the web service's ReadWritePaths, and sealed to a TPM where one exists. WireGuard keys, hub credentials, and the TOTP secrets live in separate files with separate ownership. Nothing sensitive is in the conffile that gets backed up in plain form; the backup archive is signed and optionally passphrase-protected, and the UI warns that a backup contains the node's identity.
The realistic adversary is a bored person with a transmitter or a script, not a state actor. The controls are proportionate.
| Vector | Control |
|---|---|
| Forged messages injected over RF | RF frames carry no signature, so they are marked unverified, are never auto-relayed above routine precedence, and are shown as unverified in the interface until a signed object confirms them |
| Replay of captured traffic | Content-addressed identifiers, monotonic per-origin sequence, a clock-skew-bounded acceptance window, and a duplicate store that remembers identifiers for the message lifetime |
| Relay amplification, one message becoming a storm | Hop limit, path list, deduplication, per-origin fan-out cap, and the rule that a node relays only toward a destination it has a scored path to |
| Storage exhaustion at a relay | Custody quotas by count and by bytes, precedence-aware admission, and explicit ~X FULL rejection so the offering node stops asking |
| Channel exhaustion, deliberate or accidental | Transmit budget, listen before transmit, exponential backoff, and per-peer exchange limits |
| A station that misbehaves | Automatic demotion in the router after repeated failures, an operator blocklist, and signed blocklist sharing through the directory that is advisory only, because automatic distributed banning is a governance problem wearing a technical costume |
| Hostile hub | It can deny service and read traffic that is not confidential anyway; it cannot sign as a station, cannot forge receipts, and cannot alter a message without breaking its identifier |
| Peer connection flooding | Connection caps, per-source rate limits, a proof-of-identity challenge before any state is allocated, and no unauthenticated allocation of memory or disk |
| Stolen node | Key rotation certificates published to hubs and gossiped over RF, configuration changes gated behind the administrator password, and an operator playbook for revocation |
Two nodes configured as K9MEV-1 is a silent failure with ordinary causes: a
restored backup running alongside the original, a spare built from a copied
card, or two people in a club who both picked instance 1. Traffic partly works,
because messages deduplicate by identifier; the hub refuses the second
registration with an error about a different key; and neither operator knows
why.
A node that sees a second key claiming an identifier it holds records the conflict with its source, raises it on the dashboard as a non-dismissible banner, and sends a priority message to the disputed identifier. That last part is the point: a message addressed to the identifier reaches whichever node is currently answering to it, which is exactly the node that needs telling, and it is the only channel guaranteed to do so. The message names both fingerprints and says plainly that one of the two must change its instance number.
A key change accompanied by a valid rotation certificate (5.3) is not a conflict and is not reported as one.
The reference hub ships with all of the following applied and refuses to start if the configuration still contains placeholder values.
fail2ban or equivalent on the proxy.security.txt, and a stated disclosure practice. A volunteer-run service with no way to report a problem is a service that finds out from an incident.Blue sky. Internet is up everywhere. All traffic goes over IP. RF is used only for the heartbeat and the capability beacon, which continue anyway because their purpose is to keep the propagation database current and to prove the RF path works. This is the state 99 percent of the time, and the discipline it enforces is that the RF path is exercised continuously rather than discovered to be broken during an activation.
Degraded. Some nodes have internet, some do not. This is the realistic disaster state, and it is where the design earns its keep. A node in the affected area sends over RF to a node outside the affected area, which forwards over IP to the destination, which may be anywhere. One RF hop of 200 km buys access to the entire internet. The hub is reachable by the unaffected nodes, so the directory and the mailbox still work for most of the network.
Dark. No internet anywhere in the region. Pure RF, store and forward, relaying, scheduled windows. Throughput falls to a few messages per hour per node. Precedence discipline becomes the governing factor, and the transmit budget and priority queueing are what stop the network from collapsing into mutual interference.
| Pattern | Nodes | Notes |
|---|---|---|
| Personal | 1 to 2 | Home plus go-bag. Useful only as part of a wider population |
| Club | 6 to 20 | The natural unit. A club hub, a shared group, a monthly exercise |
| ARES or RACES district | 20 to 60 | Served agency sites, EOC, hospitals, shelters. Requires a training program and a designated network coordinator |
| Regional backbone | 3 to 8 | High-sited, well-antenna'd, always-attended-capable nodes that exist specifically to relay. These deserve better antennas and 100 W amplifiers, discussed in 19 |
Every emergency communications system faces the same problem: it must be maintained for years to be useful for hours, and volunteers do not maintain things that do nothing between events. HERMES answers this deliberately rather than by exhortation.
On an ordinary Tuesday a node is: a propagation and noise measurement instrument for its own station (12.7), a contributor to PSKReporter, a participant in an automated weekly check-in net (11.4), a monitored watch on the paths its operator cares about (12.4), and an ordinary JS8Call presence that other operators can call and leave messages with (9.9). None of that requires an emergency, and all of it produces something the operator can look at.
A system that is only useful during a disaster will not be working when the disaster arrives. This is the single most important operational claim in the document, and the measurement capability of section 12 is the mechanism by which it is made true.
An emergency communications system that is not exercised is a decoration. The design supports this directly: a scheduled exercise mode generates synthetic traffic between all participating nodes at a configured rate, measures end to end delivery time and success rate by path and by transport, and produces an after-action report showing which links worked, which did not, and how the network would have performed with the internet removed. A monthly exercise that takes the operator ten minutes is the difference between a working network and a shelf full of boxes.
How it works. An exercise sends real messages over real transports through the real router. It is not a simulation; the only differences are that the traffic is marked, it goes to a participant list on a schedule, and there is a report at the end.
Every body opens with EXERCISE EXERCISE EXERCISE. The marker is not
decoration: people have acted on drill traffic, and anyone who sees a message
during a real event needs to know within three words that it is not real.
Exercise traffic may not use emergency precedence, and the software refuses
it, because a drill that takes the whole transmit budget and jumps every real
queue is the one thing a drill must never do.
The report is per participant, because an overall rate is not a finding. An exercise reporting 80 percent delivery tells an operator nothing they can act on; one that names which station was the missing 20 percent tells them where the next relay goes. Each participant gets a verdict (reliable, intermittent, poor, never reached), a median time, and the transports that carried the traffic, and the report states in words what the numbers mean:
never reached: KB9FAR. Either a relay is needed between here and there, or their station is not receiving. Check whether they heard us at all.
every message reached a node and none was recorded as delivered to a person. That is the half of the drill that usually gets skipped, and it is the half an agency asks about.
Once every few drills, unplug the network cable first. That is the state the whole system exists for, it is the state nobody tests, and the first time should not be the day it happens for real.
Revision 1.2 estimated these figures. They are now measured, by running the reference implementation's encoder, fragmentation, and ARQ across a simulated channel with injected frame loss. The simulator charges time per JS8 frame slot, which is how JS8 actually bills: a lost frame costs the same fifteen seconds as a good one.
A 25 word radiogram, JS8 Normal, compound call sign, end to end including the handshake and the receipt:
| Frame loss | Delivered | Median minutes | Median JS8 frames |
|---|---|---|---|
| 0 percent | 100 percent | 7.0 | 28 |
| 5 percent | 81 percent | 7.0 | 28 |
| 10 percent | 86 percent | 8.5 | 34 |
| 20 percent | 81 percent | 9.5 | 38 |
| 30 percent | 52 percent | 13.0 | 52 |
At JS8 Slow with 10 percent loss, 19 minutes.
Revision 1.2's estimate of 6 to 11 minutes was about right, which is reassuring, but it was right for the wrong reason: the frame accounting was understated by roughly half and the fragment overhead by roughly the same, and the two errors cancelled. Section 1.1 describes the correction.
Two caveats on these numbers. The success rate is non-monotonic at low loss because each point is only 21 runs; a publishable table needs a few hundred. And a uniform random loss model is optimistic: real HF loss is correlated, arriving in bursts as fading takes out several consecutive frames, which an ARQ window handles worse than the same average loss spread evenly. Expect real-world figures to be worse than the 20 percent row, and treat the 30 percent row as the realistic edge of usefulness rather than the worst case.
For the zero loss case, the 28 frames break down as:
| Stage | JS8 frames | Why |
|---|---|---|
| Offer | 2 | Session, count, precedence, destination, digest, plus the compound call sign frame |
| Accept | 2 | |
| Data, 5 fragments | 18 | 40 characters plus a 15 character header is 3 frames each |
| Acknowledgment | 4 | One per window, plus the call sign frame |
| Receipt | 2 |
The fragment header is 27 percent of the payload cost. A larger fragment amortizes it better and retransmits more on failure; 40 characters is where the two curves cross at the error rates HF produces. This is the single most obvious place to optimize, and the reason not to is that the crossover moves with the error rate, so a fixed larger fragment is better on a good channel and worse on the channel this system exists for.
A single JS8 channel occupies 50 Hz, and JS8Call decodes the entire 2.5 kHz passband simultaneously, so roughly 20 to 30 signals can coexist at different offsets before mutual interference becomes limiting, with practical experience suggesting 8 to 12 concurrent conversations is the realistic ceiling before collisions dominate.
At 9 minutes per radiogram and 10 concurrent sessions, one band supports approximately 60 to 65 radiograms per hour region-wide in ideal conditions, and realistically 25 to 40. Across four bands, and accounting for the fact that not all bands are open at once, a regional network's realistic dark-state capacity is 50 to 80 messages per hour.
That is the number that should govern operational planning. It is enough for command and coordination traffic for a county. It is not enough for individual welfare traffic at scale, and the priority system exists precisely so that the emergency and priority traffic gets through while welfare traffic queues, which is exactly the discipline NTS has used for a century.
5 W into an end fed half wave at 10 m height on 40 m at 300 km, at night, with a typical noise floor of -100 dBm in a suburban location, yields a predicted SNR at the receiver in the range of -5 to +10 dB by NVIS. JS8 Normal decodes to -24 dB and Slow to -28 dB, so the margin is 15 to 35 dB. QRP is not the limiting factor on regional paths. Antenna height, noise floor, and time of day dominate, in that order, and the system's own database will demonstrate this to the operator within a week.
The 1000 km and beyond paths are where 5 W becomes marginal, and that is where relaying and the Slow speed exist.
| Phase | Deliverable | Effort | Depends on |
|---|---|---|---|
| 0 | Bench prototype: Pi, QMX, JS8Call headless, API round trip proven | 1 week | Hardware in hand |
| 1 | hermes-core message object, store, signatures, canonicalization, CLI |
2 weeks | |
| 2 | hermes-rfd: framing, fragmentation, ARQ, two-node file transfer over the air |
3 weeks | 1 |
| 3 | hermes-radiod: CAT, SWR qualification, band scheduling, posture enforcement |
2 weeks | 0 |
| 4 | hermes-netd and reference hub: rendezvous, mailbox, directory |
2 weeks | 1 |
| 5 | Router, custody, receipts, receipt upgrade | 2 weeks | 2, 4 |
| 6 | hermes-web: wizard, compose, traffic, stations, radio, system |
4 weeks | all |
| 5b | Measurement: observation pipeline, rollups, link scoring, watched paths, reports and export | 3 weeks | 2 |
| 5c | hermes-vhfd: Direwolf, AX.25 UI framing, fragment transfer over 2 m |
2 weeks | 2 |
| 6b | Packaging: APT repository, signed builds, hermes-setup, update pipeline with rollback |
2 weeks | 1 |
| 6d | Forms engine, ICS set, ICS-309 generation, final delivery workflow | 3 weeks | 1, 6 |
| 6e | Net control board and fleet policy bundles | 2 weeks | 5b, 6 |
| 6c | hermes-scand: SDR channelizer, scan decoders, T/R protection validation |
2 weeks | 2 |
| 7 | Enclosure: CAD, print, fit, EMC measurement, revision | 2 weeks | 0 |
| 8 | Field trial: 4 nodes, 30 days, exercise mode, after-action | 4 weeks | 6, 7 |
| 9 | Documentation, image build, A/B update, release | 2 weeks | 8 |
Approximately 38 weeks of part-time effort to a releasable system, with a usable two-node demonstration at the end of phase 2, which is roughly six weeks in. Note that phase 5b, the measurement pipeline, delivers standalone value with a single node and could reasonably be pulled forward to run in parallel with phase 2, since it is the part that is useful before the network exists. Phase 2 is the risk-retiring milestone: if reliable fragment transfer over a real HF path at 5 W does not work, everything downstream needs rethinking, and it is worth reaching that point before building anything pretty.
Unit and integration: message canonicalization and signature round trip against fixed vectors, fragmentation and reassembly with injected loss, router scoring against a fixture database, posture engine against a table of band, frequency, and attendance combinations that must be accepted or rejected.
Loopback RF: two nodes connected by attenuated coax and a dummy load, running full sessions at controlled SNR from +20 dB down to -30 dB, producing a measured throughput and success curve per speed. This is the test that produces the real numbers to replace the estimates in section 15.
EMC acceptance: noise floor per band with the Pi off, idle, and loaded, per 6.8. Fails if the delta exceeds 3 dB on any band.
Power acceptance: 72 hour run on battery with logging, comparing measured consumption against the budget in 6.10.
Regulatory acceptance: a scripted audit that replays 30 days of the transmission log and asserts that every transmission was within an authorized posture, was inside a permitted segment or was a response to an interrogation, and that identification occurred within every 10 minute window. This produces a report the operator can keep.
Field acceptance: 30 days, four nodes, at least one of which is deliberately internet-isolated for the whole period, with weekly exercise traffic, measuring end to end delivery rate by precedence and by state.
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Pi-generated RFI desensitizes the receiver | High | High | Shielding, ferrites, measured acceptance test; fall back to an aluminum enclosure for the Pi compartment if the test fails |
| Clock drift when GPS is lost and internet is gone | Medium | Critical | DS3231 holdover, JS8Call drift estimation, loud UI alarm; a node that cannot trust its clock says so |
| 40 m ACDS segment congestion | High | Medium | Prefer 30 m, transmit budget, listen before transmit |
| Too few nodes for the network to be useful | High | High | Interoperability with plain JS8Call from day one is the primary mitigation; a HERMES node is useful in a JS8Call-only population |
| JS8Call API instability or breaking change | Medium | Medium | Support both mainline and Improved forks, version-detect, pin a known good version in the image |
| Antenna failure goes undetected | Medium | High | SWR qualification sweep, SR polling, antenna fault alarm |
| Regulatory interpretation of the beacon posture is wrong | Low | High | Every parameter is configurable; seek a second opinion before deployment, see section 20 |
| Operators do not exercise the system | High | High | Scheduled exercise mode with automatic after-action reports; the everyday measurement value of 14.3 is the primary defense, since a node that produces something on an ordinary Tuesday stays powered on |
| The project stalls or the maintainer stops | High | High | Governance in 21.1: specification and implementation separated, conformance vectors published, two-person key custody, named succession, mirrors |
| Served agencies decline because the forms and reporting are not what they use | Medium | High | Forms engine and shipped ICS set (11.5), ICS-309 generation (11.7), Winlink bridge (11.6) |
| The Winlink community objects to a bridge under an individual's account | Medium | Medium | Bridge is explicit opt in by the gateway operator, under their own identity, subject to Winlink's rules, and is not an anonymous relay |
| A fleet policy coordinator is compromised and misconfigures a district | Low | Medium | Policy schema carries no code, no keys, and no transmit commands; local override wins; every application is logged with a diff and one button reverts |
| Key management confuses volunteers | Medium | Medium | Trust on first use by default, QR fingerprints, no operator ever needs to see a key |
| 5 W is insufficient on the paths that matter | Low | High | Measured in phase 8; the QRP Labs 50 W amplifier is a documented upgrade path for backbone nodes |
| JS8Call compound call sign behavior differs from the documented cost, breaking per-instance RF identity | Medium | High | First on-air test in phase 0, across both JS8Call variants, before anything is built on the assumption |
| A node on a public 44Net or IPv6 address is compromised through the web interface | Medium | Critical | Section 12 baseline, WireGuard-preferred administration, no default credentials, automatic security updates with visible staleness indicator |
| The APT repository or its signing key is compromised | Low | Critical | Offline signing key for update bundles, published key fingerprint, reproducible builds as a goal, no unauthenticated fallback path |
| Automatic updates break a node during an activation | Medium | High | Policy interlocks (emergency declaration, priority queue, battery floor, maintenance window), health check with automatic rollback, try-boot A/B slots |
| SDR scan decoders exhaust the Pi and cause missed decodes on the main radio | Medium | Medium | Measured in phase 8; decoder count is configurable and the main JS8Call instance is given CPU priority |
| Third party traffic moves under an emergency override that is later judged unjustified | Low | Medium | Declaration requires a typed justification, expires in 12 hours, and is stamped on every message and every log line for the after-action record |
Revision 1.0 of this document ended with eight open questions. All eight have been answered by the operator, and the answers are recorded here with what each one changed, because a design document that hides its decisions behind a finished surface is much harder to review.
| # | Question | Decision | What it changed |
|---|---|---|---|
| 1 | Per-instance RF identity, or instance 0 only on the air | Hard requirement. Every instance is its own on-air station | Section 5.2 rewritten. Heartbeat interval doubled to 30 minutes, capability beacon to 120 minutes, RF transmit lease replaced by a co-site scheduler |
| 2 | Automatic control interpretation | Second opinion sought from an ARRL Volunteer Counsel; the middle reading stands in the meantime and stays configurable | Section 3.1 unchanged pending the opinion, flagged in the risk table |
| 3 | Fan dipole or linked end fed half wave as the default | Fan dipole, with the end fed half wave documented as a portable option | Section 6.6 rewritten, parts list changed |
| 4 | QMX band build | 80/60/40/30/20 m confirmed | Assumption promoted to a decision throughout |
| 5 | Hub ownership | Left to the deployer. No project hub, none configured by default. Recommended pattern is a self-hosted hub on the node over 44Net, hardened | Sections 10.1, 10.6, 10.7, and the whole of section 13 |
| 6 | Third party traffic and attended operation | Emergency override, narrow, logged, and expiring | Section 3.4 extended |
| 7 | Group namespace | Accept collisions, claim no prefix, with the ~ sigil doing the disambiguation and a rule against sending protocol frames into groups with no HERMES members |
New section 9.12 |
| 8 | QMX+ and QDX support | Included, through a radio driver abstraction | New section 6.4, with the reduced capability tier for the QDX stated plainly |
Four requirements were added in this revision: the receive-only SDR (6.9), installation on a stock image from a signed repository (8.7), a patching and update strategy that works on a read-only filesystem and offline (8.8), and a full hardening specification for everything that faces the internet (12).
A review asked six pointed questions about what a served agency would actually need and what would make anyone run this for two years before it mattered. All six are now in the document, and one architectural question was answered.
| Gap identified | Resolution |
|---|---|
| Three message profiles is not enough for a served agency, and there is no path to Winlink | Signed form template engine with the ICS set shipped (11.5), and a !WL bridge with its limits stated honestly (11.6) |
| No ICS-309 communications log, which is the artifact agencies actually ask for | Generated from data already held, one button, plus ICS-214 and an after-action summary (11.7) |
| The design is node-centric and emergency communications is organizational | Net control role with a traffic board and a station board, visibility only and no command authority (8.9) |
| HF-only is the wrong shape when most traffic travels five miles | VHF and UHF as a third transport over AX.25, preferred over HF, with no automatic control constraints on 6 m and shorter (6.11) |
| Delivery tracking stopped one hop short of a human being | Operator-asserted final delivery with method and time, service messages back to the originator, feeding the ICS-309 (11.8) |
| No way to provision or keep forty nodes aligned, and the obvious fix violates the no-remote-command rule | Pull-based signed policy bundles, narrowly scoped, local override wins, no code and no keys in the schema (8.10) |
| The measurement record was buried as a byproduct when it is the day-one value | Promoted to section 12 in full, with collection, derivation, watched paths, aggregation, export, and seven written-out use cases, and reframed in the executive summary |
@HERM is already in use. Check on the air and against community lists before release, and pick something else without argument if it is taken.Emergency communications infrastructure that depends on one enthusiastic person is a liability dressed as a project. Most amateur radio software dies when its author's attention moves, and the ones that survive did something deliberate to make survival possible. This is the proposal for doing that deliberately, from the start, rather than after it becomes urgent.
Separate the specification from the implementation. The HERMES Message Protocol is versioned independently of the software that speaks it, published as its own document, and carries a stability policy: within a major version, a node may add fields and may not change the meaning of existing ones, and any node must ignore fields it does not understand. The reference implementation becomes one implementation rather than the definition. This single decision is what makes everything below possible.
Publish conformance vectors and a test suite. This now exists rather than being proposed. Twenty-three vectors across seven groups (base 32, canonical form, station identifiers, messages and signatures, frames, envelopes and fragmentation, ARQ bitmaps), each carrying the input, the expected output, a sentence saying why it matters, and the section it comes from. python -m hermes.conformance checks an implementation against them; regeneration is deterministic and a test asserts the published file matches the generator, so a diff means a real change rather than noise.
Two independent implementations is the real insurance against project death, and nobody writes a second implementation against a codebase; they write it against a specification with tests. The vectors were chosen partly from cases the first implementation got wrong, which is the most honest source available: base 32 left-aligns a digest and keeps the low bits of an integer, and conflating those destroyed the envelope timestamp and every ARQ bitmap until a test caught it.
One consequence, stated plainly: the protocol is frozen at version 1 in practice. The rule below requires two implementations to agree before a change enters a released major version, and there is one. That is uncomfortable and it is deliberate: a protocol that changes whenever its author thinks of something better cannot be implemented by anybody else, and a project with a single implementation has no insurance at all.
Key custody, held by two people. An offline root signing key, split between two custodians in different physical locations, on hardware tokens, used only to sign an annually rotated release key. The release key signs packages and update bundles. Both fingerprints are published in this document, on the project site, and inside the packages themselves, so that a compromised distribution server cannot quietly become the source of truth. Neither custodian can produce a release alone.
A named co-maintainer from day one, with commit access, release rights, and a copy of the documentation for every process. Not a figurehead: someone who has actually cut a release.
The onboarding is written down and deliberately ends with commit access rather than beginning with it: read the whitepaper and the build record, build a node from the install guide without help (anything they get stuck on is a documentation bug and finding those is worth the exercise alone), cut a release to a test repository alone, take a bundle key custody share, and only then take commit rights. A maintainer who has never cut a release is a name on a page.
This remains the largest single risk to the project, and at the time of writing it is unaddressed. The procedure exists; the person does not. Everything else in this document is engineering that one person can do, and this is the one thing that cannot be.
A written succession clause, published with the license: if no release and no public activity occurs for twelve months, the named successors, or failing that any three participants acting together, may fork, publish under the same name, and assume the signing keys through the documented custody process. Say it in advance and the fork is a handover; say nothing and the fork is a schism.
An institutional home, chosen from three realistic options:
| Option | Strength | Weakness |
|---|---|---|
| A club as steward, for example a local ARRL-affiliated club holding the repository, the domain, and one custody share | Continuity beyond any individual, a natural test deployment, existing legal personality | Clubs have their own politics and their own attention spans |
| An ARDC grant, given that ARDC funds amateur digital infrastructure and this project sits directly in their interest area, including the 44Net pattern in 10.6 | Funding for hardware and for someone's time, plus institutional accountability and reporting discipline | Grant obligations, and a dependency on a funder's priorities |
| A plain organization account with three owners and mirrors on two independent hosts | Simplest, no politics, no obligations | No legal personality, no funding, and continuity rests on three individuals remaining reachable |
The recommendation is the club as steward plus a grant application once a working two-node demonstration exists, with mirrors regardless. The demonstration matters: a grant application for a design document is a harder sell than one for a system that has passed traffic.
Decision making is a benevolent maintainer with a public tracker, plus one rule with teeth: a protocol change requires two implementations to agree before it enters a released major version. That rule slows the protocol down, which is the point.
Security disclosure follows the practice already used on the author's other properties: an RFC 9116 security.txt on the project site, a published contact, acknowledgment within a week, and coordinated disclosure at ninety days.
The measurement archive outlives the software. The propagation and reachability dataset of section 12 is the most durable thing this project will produce, and it should be deposited somewhere that survives the project: a mirror held by the steward, an annual signed export published openly, and a documented schema so that the data is readable by anyone in twenty years without the software that produced it.
Naming. The name is used lightly and given away freely. Anyone may implement the protocol and call the result HERMES, provided it passes the conformance suite. That constraint is the only one worth enforcing, and it is enforced socially rather than legally.
The forms and reporting in section 11 exist because a structured review concluded that a served agency would otherwise decline the system. That conclusion was an informed guess by an engineer reading FEMA documentation, which is exactly the wrong way to choose a form set.
No emergency coordinator, ARES officer, or active traffic handler has reviewed this design. That is the second largest risk after the maintainer problem, and it is not fixable by more engineering: a system built entirely around what a thoughtful engineer believes emergency communications needs is a much better starting position than most projects have and is not the same thing as what the people doing the work will accept.
A short review packet exists for exactly this, ending in one question: what would make your group decline this? It names the answer that is expected and currently has no good response, which is that a group with eight years of Winlink training is being asked to learn something with one user. Publishing the question with the expected objection attached is more likely to get an honest answer than asking for feedback in general.
This document is published for review before anything is built, which is the cheapest moment to be told that something is wrong.
The specific things worth attacking:
How to send comments. Reply to the operator directly, by email or on the air. Corrections with a citation are worth more than opinions, and disagreement is worth more than approval.
License. This document, its diagrams, its schemas, and its example configurations are released under the MIT License. Use it, republish it, adapt it for your club, translate it, or build a competing system from it. Attribution is appreciated and not required.
Disclaimer. Nothing here is legal advice, and nothing here is affiliated with or endorsed by the ARRL, ARDC, QRP Labs, or the JS8Call project. The regulatory statements are one amateur's reading of the rules, and they should be confirmed independently before any station transmits under automatic control.
The design in this document has been implemented in full. This section says what that means and, more usefully, what it does not.
| Layer | State | What backs the claim |
|---|---|---|
| Message object, signatures, canonical form | proven | Round trip, tamper detection, and cross-parser stability all tested |
| Store, custody, receipts, router | proven | Custody timeout, loop refusal, hop limit, and receipt upgrade all tested |
| Measurement: ingest, rollup, link quality, watched paths | proven | Runs against a live JS8Call instance today; rollup idempotence tested against a full rebuild |
| Internet transport and hub | proven | Verified over TCP between two node processes and a hub process, including the hub-gone case |
| Web interface, authentication, forms, ICS-309 | proven | Strict content security policy asserted per page; every mutating route tested for session and token |
| Packaging, setup, update pipeline | proven | Interlocks, rollback, and signed offline bundles all tested |
| Identity backup and rotation | proven | Round trip, wrong passphrase, forged certificate, and chain-from-unknown all tested |
| Clock and power health | proven | Clock parsing tested against chrony output; power against a synthetic sensor. No INA219 has been connected |
| Delivery estimation and reporting | proven | Estimates from measured history; actual times computed from message events |
| Airtime limits and duplicate delivery | proven | |
| Exercises | proven | Marked traffic, per-participant report, findings |
| Group enforcement and identity conflicts | proven | Including the operator override and the notification to the other node |
| Hub operations and federation | proven | Including the refusal to let a peer rewrite a held key |
| Conformance vectors and harness | proven | 23 vectors, deterministic regeneration, checked in CI |
| Radio control and posture engine | partial | Complete against a mock radio. No QMX has been connected |
| HF transport: framing, ARQ, beacons, budget | partial | Complete against a simulated channel with injected loss. Never on the air |
| VHF over AX.25 | partial | Complete against a loopback link. No TNC connected |
| Receive-only SDR scanning | untested | Planning and interlocks written; the signal chain is external processes and unexercised |
| Enclosure | untested | Parametric source only; dimensions must be confirmed against a real PCB |
The largest untested surface is everything below the frame codec. The protocol has been exercised hard against a simulator that models loss as independent per frame. Real HF loss is correlated and arrives in bursts, which an ARQ window handles worse. Nothing in section 15 should be treated as validated until a QMX has been on an antenna.
Three, all deliberate, each recorded at the point in the text where it applies rather than only here:
Revision 2.1 followed a structured review that asked what was missing beyond the already-known gaps. Fifteen findings came out of it, and thirteen were implemented; the two that were not are the two that are not engineering.
Not implemented, and the more important of the two sets. There is still no second maintainer, and no emergency coordinator or active traffic handler has reviewed the design. These are the largest risks to the project and neither is fixed by writing code.
What has been built is everything that makes them possible and cheap: the conformance vectors and harness that let a second implementer work without reading this implementation's source (21.1), a written release and key custody procedure with a five-step co-maintainer onboarding that ends in them cutting a release alone, and a ten minute review packet for an emergency coordinator that ends in a single question. The procedures exist; the people do not, and no amount of further work changes that.
Implemented. Identity backup and rotation (5.3); clock health surfaced (6.7); power monitoring with the update interlock that depended on it (6.10); time-to-a-person estimated at composition and reported afterwards (11.2, 11.7); airtime ceiling (11.2); duplicate delivery detection (11.8); group namespace enforcement with an operator override (9.12); duplicate identifier detection that tells both nodes (13.5); exercise mode (14.4); hub operator interface and federation (10.5); plus an end-user quick reference and an exercise guide with a worked scenario, both of which were missing and both of which matter more than any of the code for a volunteer who has never seen the system.
Two of these changed behavior that a reader of an earlier revision would not expect, and are worth calling out. A node now holds back its capability beacon until it has heard another HERMES station in the group, which means a genuinely first node in a region transmits nothing until its operator enables it deliberately. And composition refuses an over-long RF message rather than warning about it.
Three findings were significant enough to change the specification. They are described in 1.1 and folded into 4.1, 9.10, and 15.
Not a changelog. These four are worth naming because each is a class of mistake that would have been expensive to find on the air, and because a reader implementing this independently will meet them too.
Precedence was silently dropped on receipt. The offer frame carries it, the receiving session discarded it, and every message arriving over the air was recreated as routine. On a congested channel that quietly demotes emergency traffic at exactly the moment it matters, and nothing looks broken. Any second implementation should test that precedence survives the air.
A base 32 helper built for truncating digests destroyed small integers. Left-aligning is right for taking the top 40 bits of a hash and catastrophically wrong for encoding a timestamp or an ARQ bitmap. The on-air symptom would have been fragments that never reassembled, for no visible reason.
A node could not verify its own outbound messages, because nothing had written its own public key into its own roster. It presented as a signature failure and was a missing row.
The plain-English auto-reply refused the first reply to every station, because an unseen call sign defaulted to timestamp zero and every comparison looked like a recent reply. The symptom on the air is not a crash: it is a node that ignores people who call it, which is precisely the behavior the interoperability argument in 9.9 depends on avoiding.
| Revision | Date | Changes |
|---|---|---|
| 1.0 | 10 August 2026 | Initial design, issued with eight open questions |
| 2.2 | 12 August 2026 | Governance infrastructure. Conformance vectors and harness published (21.1), so a second implementation can be written from the specification rather than from this source, with the consequence that the protocol is frozen at version 1 until a second one exists. Release procedure, two-key custody, succession, and co-maintainer onboarding written down. Review packet for an emergency coordinator, and 21.2 added stating plainly that nobody doing the work has reviewed the design |
| 2.1 | 12 August 2026 | Second implementation pass against a structured gap review. Identity backup and rotation (5.3), clock health surfacing (6.7), power monitoring and the interlock that depended on it (6.10), time-to-a-person estimation and reporting (11.2, 11.7), airtime ceiling (11.2), duplicate delivery detection (11.8), group namespace enforcement with an override (9.12), duplicate identifier detection (13.5), exercise mode made real (14.4), hub operator interface and federation (10.5). Requirements R28 to R37 added; section 22.3 records what was found and what was deliberately not built |
| 2.0 | 11 August 2026 | Revised against a complete reference implementation. Section 22 added: implementation status, the three deviations, the design changes the build forced, and the bugs worth recording. Section 15 replaced with measured throughput and a breakdown of where the time goes. Section 9.10 corrected: compression is a composition aid, not a wire transform, because the body is signed. Section 4.1 extended with the pending transport result. Section 5.2 given the measured compound call sign cost. Sections 10.4, 11.7, 13.3, and appendix B annotated with their deviations |
| 1.2 | 10 August 2026 | Response to an outside critique. Measurement promoted to section 12 as a headline capability with use cases written out and the executive summary reframed. Forms template engine and shipped ICS set, Winlink bridge, ICS-309 generation, and operator-asserted final delivery added to section 11. Net control role (8.9) and pull-based fleet policy (8.10) added. VHF and UHF added as a third transport (6.11) with the routing weights revised. Prior art table added at 1.1. Governance and continuity proposal added at 21.1 |
| 1.1 | 10 August 2026 | All eight questions resolved (section 19). Per-instance RF identity made a hard requirement with the beacon rates rebalanced. Fan dipole made the default antenna. QMX+ and QDX support added through a driver abstraction. Receive-only SDR specified as a recommended addition. Stock-image installation from a signed APT repository. Update and patching strategy for read-only and offline nodes. Full security and hardening specification added as section 13. Hub ownership devolved to deployers with a 44Net self-hosting pattern. Group namespace policy decided. Released for public review |
PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; -- Who exists CREATE TABLE stations ( id TEXT PRIMARY KEY, -- canonical 'K9MEV-1' callsign TEXT NOT NULL, -- 'K9MEV' instance INTEGER NOT NULL DEFAULT 0 CHECK (instance BETWEEN 0 AND 15), pubkey BLOB, -- 32 byte Ed25519 key_state TEXT NOT NULL DEFAULT 'unverified', -- unverified|pinned|attested|revoked key_pinned_at INTEGER, fingerprint TEXT, -- 8 char base32 of first 40 bits display_name TEXT, grid TEXT, lat REAL, lon REAL, caps TEXT, -- JSON: {relay:true,bands:[80,40,30,20],sched:2,gw:["EMAIL"]} first_seen INTEGER NOT NULL, last_seen INTEGER, last_seen_rf INTEGER, last_seen_ip INTEGER, is_self INTEGER NOT NULL DEFAULT 0, blocked INTEGER NOT NULL DEFAULT 0, notes TEXT, UNIQUE (callsign, instance) ); CREATE INDEX ix_stations_callsign ON stations(callsign); -- Raw hearing log: every decode, HERMES or not CREATE TABLE observations ( id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, -- unix seconds UTC station_id TEXT, -- null if unknown/unparseable callsign TEXT NOT NULL, band INTEGER NOT NULL, -- metres: 80,60,40,30,20 dial_hz INTEGER NOT NULL, offset_hz INTEGER NOT NULL, snr_db INTEGER, speed TEXT, -- slow|normal|fast tdrift REAL, grid TEXT, frame_type TEXT, -- HB|CQ|DIRECTED|HERMES-H|HERMES-D|... to_us INTEGER NOT NULL DEFAULT 0, raw TEXT ); CREATE INDEX ix_obs_ts ON observations(ts); CREATE INDEX ix_obs_call_band ON observations(callsign, band, ts); -- Aggregated link quality, the table the router reads CREATE TABLE links ( station_id TEXT NOT NULL, band INTEGER NOT NULL, hour_utc INTEGER NOT NULL CHECK (hour_utc BETWEEN 0 AND 23), month INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12), decodes INTEGER NOT NULL DEFAULT 0, snr_mean REAL, snr_best INTEGER, attempts INTEGER NOT NULL DEFAULT 0, successes INTEGER NOT NULL DEFAULT 0, last_ok INTEGER, quality REAL, -- computed score, see 9.6 PRIMARY KEY (station_id, band, hour_utc, month) ); -- IP-side reachability CREATE TABLE reachability ( station_id TEXT PRIMARY KEY REFERENCES stations(id), direct_ok INTEGER NOT NULL DEFAULT 0, last_direct INTEGER, last_via_hub INTEGER, hub TEXT, endpoints TEXT, -- JSON array rtt_ms INTEGER, accepts_inbound INTEGER NOT NULL DEFAULT 0, duty_schedule TEXT -- JSON: awake windows ); -- Traffic CREATE TABLE messages ( id TEXT PRIMARY KEY, -- 16 char base32 from_id TEXT NOT NULL, to_ids TEXT NOT NULL, -- JSON array profile TEXT NOT NULL, -- radiogram|message|ics213|status|receipt prec TEXT NOT NULL CHECK (prec IN ('E','P','W','R')), hlim INTEGER NOT NULL DEFAULT 4, created INTEGER NOT NULL, expires INTEGER, subject TEXT, body TEXT NOT NULL, meta TEXT, -- JSON, profile specific orig_class TEXT NOT NULL DEFAULT 'licensee', sig BLOB, digest40 TEXT NOT NULL, -- 8 char base32 canonical BLOB NOT NULL, -- RFC 8785 bytes, for re-verification state TEXT NOT NULL, -- draft|queued|inflight|custody|delivered|verified|expired|failed require_verified INTEGER NOT NULL DEFAULT 0, inbound INTEGER NOT NULL DEFAULT 0, read_at INTEGER ); CREATE INDEX ix_msg_state ON messages(state, prec, created); CREATE TABLE custody ( msg_id TEXT NOT NULL REFERENCES messages(id), holder_id TEXT NOT NULL, accepted INTEGER NOT NULL, deadline INTEGER NOT NULL, path TEXT NOT NULL, -- JSON array of station ids, loop prevention released INTEGER, PRIMARY KEY (msg_id, holder_id, accepted) ); CREATE TABLE receipts ( msg_id TEXT NOT NULL REFERENCES messages(id), by_id TEXT NOT NULL, kind TEXT NOT NULL, -- hop|delivered level INTEGER NOT NULL, -- 1..4 per 9.8 ts INTEGER NOT NULL, transport TEXT NOT NULL, -- ip|rf digest40 TEXT, sig BLOB, -- null for compact RF receipts PRIMARY KEY (msg_id, by_id, kind, ts) ); CREATE TABLE transmissions ( id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, msg_id TEXT, sid TEXT, peer_id TEXT, band INTEGER, dial_hz INTEGER, offset_hz INTEGER, speed TEXT, frame_type TEXT NOT NULL, frag_k INTEGER, frag_n INTEGER, text TEXT NOT NULL, duration_s REAL, posture TEXT NOT NULL, -- auto_b|auto_c_response|attended result TEXT -- sent|acked|timeout|swr_fault ); CREATE INDEX ix_tx_ts ON transmissions(ts); -- Radio health CREATE TABLE band_swr ( band INTEGER, freq_hz INTEGER, ts INTEGER NOT NULL, swr REAL, power_w REAL, usable INTEGER NOT NULL, PRIMARY KEY (band, freq_hz, ts) ); CREATE TABLE band_noise ( band INTEGER, hour_utc INTEGER, ts INTEGER NOT NULL, s_units REAL, pi_state TEXT, -- off|idle|loaded PRIMARY KEY (band, hour_utc, ts) ); CREATE TABLE tx_budget ( window_start INTEGER PRIMARY KEY, window_len_s INTEGER NOT NULL, tx_seconds REAL NOT NULL DEFAULT 0, by_prec TEXT ); -- Housekeeping CREATE TABLE config (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated INTEGER NOT NULL); CREATE TABLE users ( name TEXT PRIMARY KEY, pw_hash TEXT NOT NULL, role TEXT NOT NULL, totp_secret TEXT, created INTEGER NOT NULL, last_login INTEGER ); -- Measurement, section 12 CREATE TABLE tx_attempts ( id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, peer_id TEXT NOT NULL, transport TEXT NOT NULL, -- ip | vhf | hf band INTEGER, speed TEXT, msg_id TEXT, frames INTEGER, retries INTEGER, elapsed_s REAL, result TEXT NOT NULL, -- ok | timeout | rejected | aborted snr_reported INTEGER ); CREATE INDEX ix_txa_peer ON tx_attempts(peer_id, transport, band, ts); CREATE TABLE spots_of_us ( -- reverse path: who heard us id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, by_call TEXT NOT NULL, band INTEGER NOT NULL, snr_db INTEGER, source TEXT NOT NULL, -- ack | hb_reply | pskreporter grid TEXT ); CREATE TABLE path_watch ( peer_id TEXT PRIMARY KEY, label TEXT, expect_pct REAL NOT NULL, window_days INTEGER NOT NULL DEFAULT 7, expect_bands TEXT, expect_hours TEXT, degraded_snr INTEGER, alert_after_h INTEGER NOT NULL DEFAULT 12, state TEXT NOT NULL DEFAULT 'ok', -- ok | degraded | failed since INTEGER ); CREATE TABLE fleet_policy ( coordinator_id TEXT NOT NULL, version INTEGER NOT NULL, applied INTEGER NOT NULL, classes TEXT NOT NULL, -- JSON array actually applied diff TEXT, PRIMARY KEY (coordinator_id, version) ); CREATE TABLE deliveries ( -- final delivery to a human, 11.8 msg_id TEXT NOT NULL REFERENCES messages(id), ts INTEGER NOT NULL, operator TEXT NOT NULL, method TEXT NOT NULL, -- phone | in_person | email | hand | third_party received_by TEXT, outcome TEXT NOT NULL, -- served | undeliverable detail TEXT, sig BLOB, PRIMARY KEY (msg_id, ts) ); CREATE TABLE emergency_declarations ( id TEXT PRIMARY KEY, declared INTEGER NOT NULL, expires INTEGER NOT NULL, by_user TEXT NOT NULL, scope TEXT NOT NULL, -- band_plan | third_party | international | all justification TEXT NOT NULL, revoked INTEGER ); CREATE TABLE updates ( id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, kind TEXT NOT NULL, -- os | hermes | bundle source TEXT NOT NULL, from_version TEXT, to_version TEXT, result TEXT NOT NULL, -- applied | deferred | failed | rolled_back reason TEXT, health_check TEXT ); CREATE TABLE audit_log ( id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, actor TEXT, action TEXT NOT NULL, detail TEXT, ip TEXT );
Encoding alphabet for binary fields is Crockford base 32: 0123456789ABCDEFGHJKMNPQRSTVWXYZ (no I, L, O, U).
frame = sigil type [flags] SP fields sigil = "~" type = "H" / "Q" / "O" / "A" / "D" / "K" / "R" / "X" / "I" / "S" flags = 1*2(ALPHA / DIGIT) hello = "~H" SP ver SP bandmask SP "SCH" sched SP "Q" qdepth SP "NET" net SP fp ver = 1DIGIT bandmask = 4DIGIT ; 1 = enabled, order 80/40/30/20 sched = 1DIGIT qdepth = 4DIGIT ; counts E,P,W,R clamped to 9 net = "0" / "1" fp = 5(base32) ; key fingerprint prefix, 25 bits query = "~Q" [SP dest] offer = "~O" SP sid SP nfrag SP prec SP dest SP digest accept = "~A" SP sid SP start data = "~D" SP sid SP k "/" n SP payload SP crc ack = "~K" SP sid SP bitmap receipt = "~R" SP sid SP kind SP digest ; kind = "C" custody, "D" delivered reject = "~X" SP sid SP reason ; FULL BUDG DEST UNV EXP identify = "~I" SP callsign status = "~S" SP qdepth SP net SP 1*(dest SP band) sid = 4(base32) nfrag = 1*2DIGIT k = 1*2DIGIT n = 1*2DIGIT prec = "E" / "P" / "W" / "R" dest = callsign ["/" 1*2DIGIT] digest = 8(base32) ; 40 bits of SHA-256 over the wire envelope crc = 2(base32) ; 10 bit CRC over the fragment payload bitmap = 2(base32) ; 10 bits, one per fragment in window payload = 1*40(payload-char) payload-char = ALPHA / DIGIT / SP / "." / "," / "?" / "/" / "-" / "+"
1 K9MEV/1 -> @HERM ~H 1 1110 SCH2 Q0000 NET0 F7K2A 2 N9ABC -> @HERM ~H 1 1110 SCH2 Q0000 NET0 R3M8P 3 K9MEV/1 -> N9ABC ~O 7QK2 08 P N9ABC K4M2XP9T 4 N9ABC -> K9MEV/1 ~A 7QK2 01 5 K9MEV/1 -> N9ABC ~D 7QK2 01/08 NR 21 P HXG K9MEV 9 BATAVIA IL 1432Z A 7M 6 K9MEV/1 -> N9ABC ~D 7QK2 02/08 UG 10 BT JOHN SMITH 412 OAK ST BATAVIA I 3K ... fragments 03 through 06 ... 7 N9ABC -> K9MEV/1 ~K 7QK2 ZW 8 K9MEV/1 -> N9ABC ~D 7QK2 07/08 ARL SIXTY SIX WITH TRUCK X ALL WELL X BT 9P 9 K9MEV/1 -> N9ABC ~D 7QK2 08/08 MARK K9MEV AR 4T 10 N9ABC -> K9MEV/1 ~K 7QK2 ZZ 11 N9ABC -> K9MEV/1 ~R 7QK2 D K4M2XP9T 12 K9MEV/1 -> @HERM ~I K9MEV (if 9 minutes elapsed) Note that K9MEV/1 is a compound call sign, so each of its transmission bursts costs one extra JS8 frame for the call sign, per 5.2. N9ABC, operating a single node, pays nothing. The station identification in frame 12 uses the base call sign, per 3.3.
Deviation from earlier revisions: the digest carried on RF is over the wire envelope, not the canonical JSON form. A receiving station cannot compute the canonical digest: it would need every field of the canonical object, and sending them costs frames a five watt link does not have. The wire digest is 40 bits of SHA-256 over the encoded envelope that actually crossed the air, which proves exactly what the canonical digest proves in practice, namely that these precise bytes arrived intact, and costs nothing extra. The canonical digest still binds the signed object and still travels with the signed receipt upgrade over IP, so nothing is lost at level 4.
Frame 11 moves the message to Delivered (level 3). When either station next has internet, N9ABC transmits the full signed receipt object over IP and K9MEV's UI moves the message to Verified (level 4).
GET /api/v1/status node health, postures, budgets
GET /api/v1/config full configuration
PATCH /api/v1/config update configuration (admin)
POST /api/v1/setup/{step} first boot wizard
GET /api/v1/messages?state=&prec=&q= list with filters
POST /api/v1/messages compose and queue
GET /api/v1/messages/{id} full object plus timeline
DELETE /api/v1/messages/{id} cancel if not yet in custody elsewhere
POST /api/v1/messages/{id}/retry force re-route
GET /api/v1/messages/{id}/receipts proof chain
GET /api/v1/messages/{id}/render.pdf radiogram or ICS-213 form
GET /api/v1/stations roster
GET /api/v1/stations/{id} detail plus heat map data
POST /api/v1/stations/{id}/verify pin or accept a key
POST /api/v1/stations/{id}/block
GET /api/v1/observations?since=&band= raw hearing log
GET /api/v1/links?band=&hour= aggregated quality
GET /api/v1/paths watched paths and their current state
PUT /api/v1/paths/{peer} set or change an expectation
GET /api/v1/reports/reachability availability by peer, band, hour, period
GET /api/v1/reports/noise noise floor trend with change points
GET /api/v1/reports/coverage group coverage gaps (aggregator role)
GET /api/v1/export/observations.csv raw decode log
GET /api/v1/export/links.json link table
GET /api/v1/export/log.adi ADIF for a normal logging program
GET /api/v1/reports/ics309?from=&to= communications log, PDF or CSV
GET /api/v1/reports/ics214 operator activity log
GET /api/v1/forms installed form templates
POST /api/v1/messages/{id}/deliver assert final delivery (method, time, recipient)
GET /api/v1/net/board net control traffic board
GET /api/v1/net/stations net control station board
POST /api/v1/net/report submit a netstat report (peers)
GET /api/v1/fleet/policy current subscription and applied version
POST /api/v1/fleet/subscribe pin a coordinator identity
POST /api/v1/fleet/revert return to local configuration
GET /api/v1/radio band, swr, noise, budget
POST /api/v1/radio/band manual band change (attended)
POST /api/v1/radio/sweep run SWR qualification
POST /api/v1/attend assert control operator, with timeout
DELETE /api/v1/attend release
GET /api/v1/network hub and peer sessions
POST /api/v1/network/hubs add or remove a hub
GET /api/v1/exercise exercise status and reports
POST /api/v1/exercise start a scheduled exercise
GET /api/v1/system/logs?service=
POST /api/v1/system/backup signed tarball
POST /api/v1/system/update
GET /api/v1/system/compliance?days=30 regulatory audit report
GET /api/v1/system/updates update history, current versions, staleness
POST /api/v1/system/updates/check check now
POST /api/v1/system/updates/apply apply now, subject to the same policy interlocks
POST /api/v1/emergency declare, with scope and justification (admin)
DELETE /api/v1/emergency/{id} revoke a declaration
GET /api/v1/scan SDR scan status, bands, decoder health
WS /api/v1/events push: decodes, state changes, alarms
identity:
callsign: K9MEV
instance: 1
licence_class: extra
grid: EN51uu # overridden by GPS when locked
station_name: "Batavia Home"
radio:
model: qmx # qmx | qmxplus | qdx
bands: [80, 40, 30, 20]
cat_port: /dev/serial/by-id/usb-QRP_Labs_QMX-if00
audio_device: "hw:CARD=QMX"
max_swr: 2.5
sweep_interval_h: 24
power_w: 5
scan: # receive-only SDR, section 6.9
enabled: false
device: rtlsdr # rtlsdr | rsp1b | airspyhf
bands: [80, 40, 30, 20]
antenna: separate # separate | shared_tr_switch
api_ports: [2443, 2444, 2445, 2446]
js8call:
variant: mainline # mainline | improved
api_host: 127.0.0.1
api_port: 2442
speeds_unattended: [normal, slow]
heartbeat_interval_min: 15
posture:
automatic_channels:
80: 3586000
40: 7100500
30: 10140500
20: 14101500
attended_channels:
80: 3578000
40: 7078000
30: 10130000
20: 14078000
allow_60m: false
attend_timeout_min: 60
third_party_requires_attended: true
updates:
mode: security # off | security | full | manual
window_utc: "09:00-11:00"
days: [Tue, Wed, Thu]
stage_days: 3
battery_floor_pct: 60
reboot_policy: window # never | window | required-only
defer_on_emergency: true
defer_on_priority_queue: true
offline_bundles_allowed: true
security:
web_public: false # expose the UI beyond LAN and WireGuard
totp_required_for_admin: false
ssh_allowed_networks: ["192.168.0.0/16", "10.0.0.0/8"]
peer_listen_public: true
failed_login_lockout: { attempts: 5, minutes: 15 }
budget:
duty_pct_hour: 15
duty_pct_day: 8
routine_share_pct: 40
quiet_hours_utc: []
hello_interval_min: 120 # 60 for a bare call sign, see 5.2
heartbeat_interval_min: 30 # 15 for a bare call sign
routing:
prefer_internet: true
max_hops_auto: 2
max_hops_emergency: 4
custody_quota_messages: 200
custody_quota_bytes: 2000000
message_ttl_h: 72
network:
hubs: [] # none by default, see 10.1
# - https://hub.example.org
# - https://hub-44.example.org # self-hosted on 44Net, see 10.6
direct_listen: true
listen_port: 8443
wireguard_profile: null
power:
battery_ah: 20
low_voltage_cutoff: 11.8
saver_below_pct: 40
saver_duty: { listen_min: 10, per_min: 30 }
web:
bind: 0.0.0.0
tls: self-signed
ap_fallback_ssid: HERMES-K9MEV
ap_address: 192.168.44.1
// HERMES enclosure, parametric. Verify the MEASURE block against the // actual QMX PCB revision before printing. QMX PCB revisions have // moved connector positions between Rev 1 and Rev 5. /* [MEASURE - confirm these seven against your hardware] */ qmx_pcb_w = 92; // PCB width qmx_pcb_d = 60; // PCB depth qmx_hole_inset = 3.5; // mounting hole inset from edge qmx_lcd_x = 8; // LCD window origin from PCB left qmx_lcd_w = 62; // LCD window width qmx_enc_1_x = 16; // left encoder shaft center qmx_enc_2_x = 76; // right encoder shaft center /* [Shell] */ wall = 2.4; chamfer = 3; fillet_top = 2; box_w = 175; box_d = 125; box_h = 62; partition_x = 100; partition_t = 3; /* [Pi] */ pi_w = 85; pi_d = 56; pi_hole = 3.5; pi_standoff_h = 6; cooler_clearance_h = 25; /* [Detail] */ insert_d = 4.2; // M3 heat set outer diameter insert_h = 5.0; louver_slot = 1.6; louver_pitch = 3.0; louver_angle = 45; lightpipe_d = 5.0; plate_w = 100; plate_h = 18; plate_text_h = 8; callsign = "K9MEV-1"; module shell_bottom() { /* chamfered box, compartments, standoffs, louvers */ } module shell_top() { /* lid, fuzzy skin surface, vent, insert bosses */ } module faceplate() { /* LCD window, encoder holes, lightpipes, plate rail */ } module callsign_plate(txt = callsign) { /* dovetail, embossed text */ } part = "all"; // all | bottom | top | face | plate if (part == "bottom") shell_bottom(); else if (part == "top") shell_top(); else if (part == "face") faceplate(); else if (part == "plate") callsign_plate(); else { shell_bottom(); translate([0,0,box_h]) shell_top(); }
| Term | Meaning |
|---|---|
| ACDS | Automatically controlled digital station, per 47 CFR 97.221 |
| ARQ | Automatic repeat request, the retransmission scheme in 9.4 |
| Attended posture | A control operator has asserted presence through the UI, with a timeout |
| Capability beacon | The ~H frame advertising what a node can do |
| Custody | Explicit transfer of delivery responsibility between nodes |
| Digest40 | The first 40 bits of SHA-256 over the canonical message form |
| Hub | The small central rendezvous, presence, mailbox, and directory service |
| Instance | The -N suffix distinguishing multiple nodes under one call sign |
| Posture | The legal operating mode: A (unattended beacon), B (unattended response), C (attended) |
| Receipt upgrade | Replacing a compact RF receipt with a full signed receipt when IP returns |
| Transmit budget | The rolling duty cycle limit enforced by hermes-rfd |
| Co-site scheduler | The mechanism preventing two of one operator's own nodes from transmitting into each other |
| Layout A / Layout B | Read-write root, versus read-only root with an overlay and A/B slots |
| Update bundle | A signed offline update package applied from removable media |
| 44Net | AMPRNet, the amateur radio IPv4 allocation administered by ARDC |
| Watched path | A peer link with a stated availability expectation and an alarm when it is not met |
| Policy bundle | A signed, narrowly scoped configuration document a node pulls from a coordinator it has pinned |
| Netstat report | The periodic queue, custody, and health summary a node sends to a net control station |
| Final delivery | An operator's assertion that a human being received the message, with method and time |
| Reverse spot | An SNR report from a station that heard us, as distinct from a station we heard |
security.txt)Revision 2.2, revised against a reference implementation. Regulatory statements are the author's reading and should be confirmed independently before deployment. All performance figures marked as estimates require measurement in phase 8. MIT licensed, see section 21.