Add PTT, which brings the transmit level bar with it

PTT was the one input the model never had, so the radio could not be keyed and the
mic level bar was unreachable. It is not a matrix key -- GPIO_IsPttPressed reads
PB10 directly (driver/gpio.h:31, active low) -- so it gets its own GPIO line rather
than a column/row intersection.

With it the transmit screen is complete: TX annunciator, a running timer, and a
level bar at roughly 80% of scale, fed from REG_64 via BK4819_GetVoiceAmplitudeOut.
app/app.c:1700 draws that only while gCurrentFunction == FUNCTION_TRANSMIT with
gSetting_mic_bar set; the setting is Data[7] bit 4 at flash 0xA0A8, and blank flash
reads 0xFF, so it is already on.

Exposed as a boolean on the keypad device and as POST /api/ptt with an explicit
held flag, plus a button in the browser UI. Held rather than tapped, because
transmitting is a state the operator stays in and a fixed duration would be wrong
for it.

Releasing is treated as the important half:
  - pointerleave and pointercancel release, so dragging off the button cannot leave
    the radio keyed
  - pagehide releases, so closing the tab cannot either
  - /api/release-all clears PTT too, since it is outside the matrix and an empty
    press does not touch it
  - non-boolean bodies are rejected, so {"held": "false"} cannot key the transmitter
    by truthiness

Two things the test suite caught, both real:

The browser wired '.key' handlers over every styled button, and the PTT button
carries no data-key, so it would have sent the key "undefined". Narrowed to
'.key[data-key]'.

StubClient.presses() collected every qom-set regardless of property, so PTT's
booleans landed among the key names and presses()[-1] reported False after a
release-all. Now filtered by property, with a matching ptts() accessor.

tools/test_ptt.py covers the path end to end and asserts the release as well as the
press: a PTT that stuck would leave every later test running against a transmitting
radio.
This commit is contained in:
mckero committed 2026-08-29 05:16:42 +01:00
1 parent 39195b8028
commit 88994b1bd0
4 files changed
+371 -4

No files matched your search

+58 -2
View File
@@ -25,8 +25,19 @@ class StubClient:
return {}
def presses(self):
"""The sequence of values written to the keypad press property."""
return [a["value"] for n, a in self.sent if n == "qom-set"]
"""The sequence of values written to the keypad press property.
Filtered by property name, because the keypad device also carries "ptt". An
earlier version collected every qom-set and so mixed PTT's booleans in with the
key names, which made presses()[-1] report False after a release-all.
"""
return [a["value"] for n, a in self.sent
if n == "qom-set" and a.get("property") == "press"]
def ptts(self):
"""The sequence of values written to the keypad ptt property."""
return [a["value"] for n, a in self.sent
if n == "qom-set" and a.get("property") == "ptt"]
def make_app():
@@ -104,6 +115,51 @@ class TestKeyEndpoint(unittest.TestCase):
self.assertEqual(resp.status_code, 200)
self.assertEqual(self.client.presses()[-1], "")
def test_release_all_also_releases_ptt(self):
"""PTT is not in the matrix, so an empty press does not clear it.
Without this, a client that vanished mid-transmission would leave the emulated
radio keyed indefinitely.
"""
self.http.post("/api/ptt", json={"held": True})
resp = self.http.post("/api/release-all")
self.assertEqual(resp.status_code, 200)
self.assertEqual(self.client.ptts()[-1], False)
class TestPttEndpoint(unittest.TestCase):
def setUp(self):
self.client, self.http = make_app()
def test_holding_ptt_sets_the_property(self):
resp = self.http.post("/api/ptt", json={"held": True})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.get_json()["ptt"], True)
self.assertEqual(self.client.ptts(), [True])
def test_releasing_ptt_clears_the_property(self):
self.http.post("/api/ptt", json={"held": True})
self.http.post("/api/ptt", json={"held": False})
self.assertEqual(self.client.ptts(), [True, False])
def test_non_boolean_is_rejected(self):
"""A string "true" must not be taken as held.
Truthiness would make {"held": "false"} key the transmitter, which is the
wrong way round for the one control that puts a signal on the air.
"""
for value in ("true", 1, None, "yes"):
resp = self.http.post("/api/ptt", json={"held": value})
self.assertEqual(resp.status_code, 400, f"accepted {value!r}")
self.assertEqual(self.client.ptts(), [])
def test_ptt_is_logged_with_the_client_ip(self):
self.http.post("/api/ptt", json={"held": True},
headers={"X-Forwarded-For": "172.21.91.140"})
entries = self.http.get("/api/logs?since=0").get_json()["entries"]
self.assertTrue(any("PTT down" in e["text"] and e["ip"] == "172.21.91.140"
for e in entries), entries)
class TestStream(unittest.TestCase):
def setUp(self):