1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
|
# M25/1 — MAX25 Terminal ↔ max25d protocol
**Version:** M25/1 (stable for `max25-terminal` / `max25-client`)
Line-oriented UTF-8 text. One command or response per line, terminated by `\n` (LF). Optional `\r` before `\n` is stripped by the daemon.
Developer guide: [docs/MAX25-CLIENT.md](../../docs/MAX25-CLIENT.md)
---
## Transport
| Method | Default | Override |
|--------|---------|----------|
| TCP | `0.0.0.0:7325` | `max25d.ini` `[network]`, env `MAX25_HOST` / `MAX25_PORT` |
| Unix stream | `/run/max25/modem.sock` | `max25d.ini`, env `MAX25_UNIX`; without root use a writable path or TCP |
The official client tries Unix first (if configured), then TCP.
---
## Connection handshake
On connect, **the daemon sends first** (client must not send before reading).
### Without TCP auth (`tcp_password` empty)
```
OK
STATUS hardware=<hw> device=<selected> devices=<id1,id2,...> mode=<mode> callerid=<id> callid=<id> ax25_ui=on|off connected=yes|no stack=<state> serial=<state> error=valid|invalid voice=valid|invalid
```
### With TCP auth (`[network] tcp_password` set — **TCP only**)
Unix socket clients skip auth (local trust).
```
AUTH required
AUTH <password>
OK
STATUS hardware=…
```
Client sends the password as a single line: `AUTH <password>` (plain text, v1).
Wrong or missing password → `ERR auth failed` and the connection closes.
| Client flag | Env |
|-------------|-----|
| `-P`, `--password` | `MAX25_TCP_PASSWORD` |
`<state>` for `stack=` is typically `running`, `stopped`, or `error`.
Implementations **must buffer** incomplete lines across `read()` calls.
---
## Client → daemon commands
| Command | Description |
|---------|-------------|
| `PING` | Keepalive |
| `GET STATUS` | Query state → `STATUS …` then `OK` |
| `GET DEVICES` | List enabled devices → one `DEVICE …` line each, then `OK` |
| `SET DEVICE <id>` | Select TX target device for this session (`SELECT DEVICE` alias) |
| `SET CALLERID <id>` | Live source callsign (uppercase) |
| `SET CALLID <id>` | Live destination callsign |
| `SET AX25_UI on\|off` | Toggle AX.25 UI framing for TX |
| `CONNECT` | Attach modem session on all enabled devices (required before `SEND`) |
| `DISCONNECT` | Detach session (daemon keeps running) |
| `SEND <text>` | Transmit line on session-selected device — payload is remainder of line after `SEND ` (may be empty) |
| `MONITOR on\|off` | RX-only mode (`SEND` → `ERR monitor-only`) |
| `BAN <callsign>` | Block AX.25 source — incoming UI frames dropped silently |
| `UNBAN <callsign>` | Remove source from ban list |
| `BANS` | List banned callsigns → one `BAN …` line each, then `OK` |
Command keywords are case-sensitive except `SET AX25_UI` flags (`on`/`off` case-insensitive).
### CALLSIGN rules
AX.25 address text (see [docs/PACKET-RADIO.md](../../docs/PACKET-RADIO.md)):
- Call body: **1–6** chars `A–Z` `0–9`
- SSID: optional `-0` … `-15`
Invalid `SET CALLERID` / `SET CALLID` → `ERR invalid CALLERID` / `ERR invalid CALLID`.
---
## Daemon → client responses
| Line | Meaning |
|------|---------|
| `OK` | Command succeeded |
| `ERR <message>` | Command failed |
| `STATUS hardware=… device=… devices=… mode=… callerid=… callid=… ax25_ui=… connected=… stack=… serial=… error=valid\|invalid voice=valid\|invalid` | State snapshot |
| `DEVICE id=… hardware=… serial=… stack=… enabled=… error=… voice=…` | One enabled device (`GET DEVICES`) |
| `RX device=<id> <text>` | Received traffic from `<id>` for display |
| `RX <text>` | Loopback TX echo (no serial) or legacy single-device |
| `EVENT connected` | Session attached (`CONNECT`) |
| `EVENT disconnected` | Session detached (`DISCONNECT`) |
### Multi-line command replies
| Command | Response sequence |
|---------|-------------------|
| `PING` | `OK` |
| `GET STATUS` | `STATUS …` → `OK` |
| `GET DEVICES` | `DEVICE …` (one per enabled id) → `OK` |
| `SET DEVICE <id>` / `SELECT DEVICE <id>` | `OK` or `ERR unknown device: …` |
| `SET CALLERID` / `SET CALLID` / `SET AX25_UI` / `MONITOR` | `OK` or `ERR …` |
| `CONNECT` | `EVENT connected` → `OK` |
| `DISCONNECT` | `EVENT disconnected` → `OK` |
| `SEND <text>` | `RX device=<id> <framed>` → `OK` (sender); other clients get `RX …` only |
With `ax25_ui=on`, framed text looks like: `[AX25 UI <callerid>><callid>] <payload>`.
### Errors
| Condition | Response |
|-----------|----------|
| Unknown command | `ERR unknown command: <word>` |
| `SEND` without `CONNECT` | `ERR not connected` |
| `SEND` in monitor mode | `ERR monitor-only` |
| Invalid UTF-8 line | `ERR invalid utf-8` |
---
## Session model
- `max25d` holds **global** `callerid`, `callid`, `ax25_ui` for all clients.
- `device=` in `STATUS` is the **session TX target** (default: `[devices] default=` or first enabled).
- `devices=` lists all enabled device ids (comma-separated).
- `connected` is per-daemon-session state (shared across clients in current implementation).
- `MONITOR` is per-daemon global flag in current implementation.
- Hardware lifecycle (`stack=running`) is owned by `max25d` per device, not the terminal.
- Each enabled device owns one serial port exclusively (one `KissBridge` each).
## Multi-device configuration (legacy)
> **Host layout:** **1× Main** + optional **5+ Secondaries** per server — [ARCHITECTURE.md](../../docs/ARCHITECTURE.md#host-layout--main--secondaries). Multi-id syntax below remains for backward compatibility.
`max25d.ini` `[devices]` section (see `share/max25/max25d.ini.example`):
```ini
[devices]
default = tnc2c
tnc2c = /dev/ttyS4
```
Legacy multi-id example (deprecated for new sites):
```ini
pktnc2 = /dev/ttyS5
```
Legacy single-device configs (`[daemon] device=` + optional `[serial]`) remain valid.
Per-device serial overrides: `[serial.<id>]` sections (baud, line, dtr_rts, kiss_entry).
### Reporting (`[reporting]` in INI)
| Key | Default | Meaning |
|-----|---------|---------|
| `error_transmissions` | `yes` | `yes`: `error=valid` when link healthy and last AX.25 decode OK; `no`: always `error=invalid` |
| `voice_transmissions` | `yes` | `yes`: `voice=valid` when acoustic/CRDOP path ready; `no`: always `voice=invalid` (TNC-only: `voice=valid`) |
Invalid AX.25 UI frames on serial backends emit `EVENT device=<id> error=invalid` when `error_transmissions=yes`.
---
## Example session
```
← OK
← STATUS hardware=tncs device=tnc2c devices=tnc2c mode=standalone callerid=CB-0 callid=QST ax25_ui=on connected=no stack=stopped
→ SET DEVICE tnc2c
← OK
→ CONNECT
← EVENT connected
← OK
→ SET CALLERID DG1ABC
← OK
→ SEND 73
← RX device=tnc2c [AX25 UI DG1ABC>QST] 73
← OK
→ GET STATUS
← STATUS hardware=tncs device=tnc2c devices=tnc2c,pktnc2 mode=standalone callerid=DG1ABC callid=QST ax25_ui=on connected=yes stack=stopped
← OK
→ DISCONNECT
← EVENT disconnected
← OK
```
(`→` client, `←` daemon)
---
## Reference code
| Component | Path |
|-----------|------|
| C client library | `stacks/terminal/max25_proto.c` |
| Terminal UI | `stacks/terminal/max25_terminal.c` |
| Daemon server | `stacks/daemon/max25d` |
| Offline smoke test | `stacks/daemon/test_proto.py` |
| Multi-device tests | `stacks/daemon/test_multi_device.py` |
---
## Stability
M25/1 is the long-term binding contract for **`max25-terminal` only**. New features should extend this protocol with documented, backward-compatible lines — not introduce a parallel client protocol.
|