summaryrefslogtreecommitdiff
path: root/stacks/crdop/lib/m25_host_protocol.py
blob: 9212336d6fd0a13d587252479553447bbae311b4 (plain)
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
"""
MAX25 native host protocol for SoftModem / audio-dummy (design freeze).

Payload on data channel = AX.25 UI body **without** HDLC/FCS (same as KISS DATA).
Control channel = line-oriented ASCII commands (M25-family, not ARDOP FEC/ARQ).
"""
from __future__ import annotations

import socket
import threading
from typing import Callable, Optional

# KISS-compatible data semantics; control is text lines ending in \\n
DEFAULT_CTRL_PORT = 8515
DEFAULT_DATA_PORT = 8516

CmdFn = Callable[[str], str]
DataRxFn = Callable[[bytes], None]


class M25SoftModemHost:
    """Minimal TCP host for bench / audio-dummy (ctrl + data ports)."""

    def __init__(
        self,
        ctrl_port: int = DEFAULT_CTRL_PORT,
        data_port: int = DEFAULT_DATA_PORT,
        on_data_tx: Optional[Callable[[bytes], str]] = None,
    ) -> None:
        self.ctrl_port = ctrl_port
        self.data_port = data_port
        self._on_data_tx = on_data_tx or (lambda _b: "OK")
        self._stop = threading.Event()
        self._ctrl_srv: Optional[socket.socket] = None
        self._data_srv: Optional[socket.socket] = None
        self._threads: list[threading.Thread] = []
        self._mycall = "NOCALL-0"
        self._listen = True

    def start(self) -> None:
        self._ctrl_srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._ctrl_srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self._ctrl_srv.bind(("127.0.0.1", self.ctrl_port))
        self._ctrl_srv.listen(4)
        self._data_srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._data_srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self._data_srv.bind(("127.0.0.1", self.data_port))
        self._data_srv.listen(4)
        self._stop.clear()
        for target, name in ((self._accept_ctrl, "ctrl"), (self._accept_data, "data")):
            t = threading.Thread(target=target, name=f"m25-host-{name}", daemon=True)
            t.start()
            self._threads.append(t)

    def stop(self) -> None:
        self._stop.set()
        for srv in (self._ctrl_srv, self._data_srv):
            if srv is not None:
                try:
                    srv.close()
                except OSError:
                    pass
        self._ctrl_srv = None
        self._data_srv = None

    def _accept_ctrl(self) -> None:
        assert self._ctrl_srv is not None
        while not self._stop.is_set():
            try:
                self._ctrl_srv.settimeout(0.5)
                conn, _ = self._ctrl_srv.accept()
            except (OSError, socket.timeout):
                continue
            threading.Thread(
                target=self._ctrl_session,
                args=(conn,),
                daemon=True,
            ).start()

    def _ctrl_session(self, conn: socket.socket) -> None:
        buf = b""
        try:
            conn.settimeout(0.5)
            while not self._stop.is_set():
                try:
                    chunk = conn.recv(4096)
                except socket.timeout:
                    continue
                if not chunk:
                    break
                buf += chunk
                while b"\n" in buf:
                    raw, buf = buf.split(b"\n", 1)
                    line = raw.decode("ascii", errors="replace").strip()
                    if not line:
                        continue
                    reply = self._handle_cmd(line)
                    conn.sendall((reply + "\n").encode("ascii"))
        finally:
            conn.close()

    def _handle_cmd(self, line: str) -> str:
        parts = line.split()
        cmd = parts[0].upper() if parts else ""
        if cmd == "INITIALIZE":
            return "OK"
        if cmd == "PROTOCOLMODE" and len(parts) > 1 and parts[1].upper() == "KISS":
            return "OK"
        if cmd == "MYCALL" and len(parts) > 1:
            self._mycall = parts[1].upper()
            return "OK"
        if cmd == "LISTEN":
            self._listen = len(parts) < 2 or parts[1].upper() in ("TRUE", "1", "ON", "YES")
            return "OK"
        if cmd == "PING":
            return "OK"
        if cmd == "STATUS":
            return f"STATUS ready mycall={self._mycall}"
        return "ERR unknown command"

    def _accept_data(self) -> None:
        assert self._data_srv is not None
        while not self._stop.is_set():
            try:
                self._data_srv.settimeout(0.5)
                conn, _ = self._data_srv.accept()
            except (OSError, socket.timeout):
                continue
            threading.Thread(
                target=self._data_session,
                args=(conn,),
                daemon=True,
            ).start()

    def _data_session(self, conn: socket.socket) -> None:
        try:
            payload = conn.recv(4096)
            if payload:
                reply = self._on_data_tx(payload)
                conn.sendall(reply.encode("ascii", errors="replace"))
        finally:
            conn.close()
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com