A Linux client for a Green Heron Everyware station — the remote antenna switch panel (4 switches × 9 antennas) and an attached rotator such as an RT-21 — speaking the device's undocumented TCP protocol on port 10000. Both arrive on one connection, so one client serves both.
Three front ends over one client library — a terminal panel, a GTK4 desktop app, and an MQTT bridge with Home Assistant discovery.
./gh-panel 192.0.2.10 # curses TUI; your server's address
./gh-panel 192.0.2.10 --position 2 # just your position's column
./gh-gui 192.0.2.10 # GTK4 window: antenna grid + rotator dial
./gh-gui 192.0.2.10 --position 2 # just your operating position's column
./gh-gui 192.0.2.10 --rotor-only # just the dial
./gh-mqtt 192.0.2.10 --broker 192.0.2.20
export GH_SWITCH_HOST=... # ...or set this and pass no address
Beam-20 is live on AS-84F-2; the other three switches show it as in use · AS-84F-2
and cannot select it. Everything else sits on OFF. On the right, the rotator: the
orange needle is where the antenna actually is, the green marker is what was asked
for.
The same grid in the terminal:
Green Heron switch panel — 192.0.2.10:10000 connected 0m08s
Antenna AS-84F-1 AS-84F-2 AS-84F-3 AS-84F-4
Beam-10 · · · ·
Beam-15 · · · ·
Beam-20 ◦ 2 ● ON ◦ 2 ◦ 2
EFHW-40 · · · ·
...
OFF ● ON · ● ON ● ON
Rotor 62.9° asked 64.3° Δ1.4°
↑↓ port · ←→ switch · ⏎ select · o OFF · t turn · r raw · q quit
t prompts for a heading and sends it to the rotator; an empty line cancels. Two
steps, for the same reason the GTK dial has them — there is no stop command, so no
single keystroke may aim an antenna. With more than one rotator, n picks which.
--position works here too, and p cycles it without leaving the panel. The header
says which column you are looking at, so hidden switches never read as missing ones.
The key line only offers what is actually available — no t when no rotator is
reported.
--dry-run exercises the panel without transmitting anything. --no-keepalive omits
the 5 s NUL the official client sends.
Everything but the GTK4 window is pure standard library. The window needs PyGObject and GTK 4, which are distro packages rather than wheels:
| Fedora | sudo dnf install python3-gobject gtk4 |
| Debian / Ubuntu | sudo apt install python3-gi gir1.2-gtk-4.0 |
| Arch | sudo pacman -S python-gobject gtk4 |
git clone https://github.com/<you>/everyware-linux && cd everyware-linux
export GH_SWITCH_HOST=192.0.2.10 # your Everyware server
./gh-guiThere is nothing to build and nothing to install into site-packages; the launchers run
from the checkout. To get a desktop launcher, copy packaging/gh-gui.desktop into
~/.local/share/applications/ and either put GH_SWITCH_HOST in your session
environment (~/.config/environment.d/gh.conf) or edit its Exec= line to name your
server.
Only Fedora is verified — that is what this was developed and hardware-tested on. The other two package sets are the standard names for GTK 4 with Python bindings, not something that has been run here.
Four columns are useful for seeing the whole station; at the radio you are only
ever one of them. --position 2 shows that switch alone, next to the rotator —
in the GTK window and the curses panel both, changed live from the header-bar
dropdown or with p.
It takes either the position number or the full switch name (--position AS-84F-2),
and it is a display filter and nothing else: all four switches are still tracked, so
Beam-20 still greys out as in use · AS-84F-3 when a neighbour takes it. A position
the device is not reporting falls back to showing all four, because a mistyped
position should not produce a blank panel.
Rotator support is in the GTK4 client, alongside the antenna grid or on its own with
--rotor-only. It was developed against an RT-21.
Confirmed end to end on that hardware in both directions: the panel tracks the RT-21's reported heading, and pressing Turn moved it — 55.6° to a settled 68.5° on a commanded 67.6°. See NOTES.md for the sampled timeline.
One thing to know before you trust a heading: the rotator stops about two degrees
short, along whichever way it was turning. Out and back on one run measured 2.5° and
2.1° short, opposite signs in absolute terms, so correcting for a fixed offset would
double the error one way. Its position reading also wanders over a ±3.8° band while
mechanically stationary — wider than that error, and visible in the official Windows
client too, so it is the rotator and not this software. Together they are why every
front end here shows you asked 64.3° Δ2.1° and never tells you that you have arrived.
Once it is inside its deadband, sending the same heading again does nothing — the controller already believes it is there. Nudge it by eye instead of looping.
A dial appears for each rotator the server reports, keyed by its configured name —
the rotator here is called Rotor, which is a name and not a type. No rotator on the
server means no dial: the device announces one only while its controller is powered on,
so silence is how absence is reported, and the panel takes it at face value.
Choosing a heading and sending one are separate gestures. Clicking the dial, or
pressing a compass preset, only proposes a heading — it appears as the dashed grey
marker and fills the entry box. Turn (or Enter) is what transmits. That is not
caution for its own sake: nothing resembling a stop or park command exists anywhere in
this protocol, so a turn that starts cannot be recalled in software. There is
deliberately no double-click-to-send.
The needle is the reported heading, never the commanded one. This rotator overshoots,
then settles about two degrees short along whichever way it was turning, and dithers by
up to a couple of degrees at rest. So 62.9° under a command of 64.3° is the hardware
behaving normally. The panel shows both numbers and the difference, and never claims
"on target" — at the measured dither, any such threshold would flicker.
If the reported heading goes quiet for ten seconds the dial greys out and Turn is
disabled. Powering the rotator controller off does not drop the connection — the switch
records keep coming — so silence is the only evidence that the controller is gone.
Working. The protocol is decoded in both directions and verified against the hardware:
this client's SET_SWITCH bytes are byte-identical to those of the official GH
Everyware Client, and the device responds to both identically.
- Live state for all four switches, refreshed every ~0.5–3.4 s
- Antenna selection, confirmed by the device in ~123 ms
- Lock display — antennas held by another switch are marked
◦ N - Reconnect with backoff; last known state stays visible, flagged stale
- Rotator heading and
TURN, in the GTK4 client — decoded, then sent to a real RT-21 and confirmed to move it
A few SWITCHADD sub-fields still have no established meaning; they are parsed and
carried but nothing branches on them. See NOTES.md for the full protocol.
pip install -r requirements-mqtt.txt
./gh-mqtt 192.0.2.10 --broker 192.0.2.20 --username ha --password ...
Each switch is announced as a select entity whose options are the antenna
names the device advertises, so all four appear under one HA device as four
dropdowns. That is the entity type that matches the hardware: a switch is on
exactly one of nine ports, which is a choice, not a set of toggles. Nothing to add
to configuration.yaml — discovery configs are published retained, so Home
Assistant repopulates on its own after a restart.
| topic | |
|---|---|
greenheron/availability |
online / offline — also the last will |
greenheron/as_84f_1/state |
selected port, retained |
greenheron/as_84f_1/set |
write a port name here to select it |
greenheron/as_84f_1/attributes |
JSON: locks, holder, ports, wireless signal |
greenheron/rotor/rotor/heading |
reported heading in degrees, retained |
greenheron/rotor/rotor/target |
last heading commanded, retained |
greenheron/rotor/rotor/set |
write a heading here to turn |
greenheron/rotor/rotor/attributes |
JSON: status, seconds since last report, announced |
greenheron/rotor/rotor/availability |
per rotor, separate from the bridge's own |
A rotator becomes two entities, not one: a read-only sensor for the heading it
reports, and a number for the heading you ask for. That split is the hardware talking.
This rotator settles a couple of degrees short and does not close the gap when you send
the same heading again, so a single slider bound to the reported heading would drift
under your hand and never sit where you put it. The number echoes what was asked; the
sensor says where the antenna is; nothing pretends they are the same. For the same
reason the number steps in whole degrees — the sensor resolves about half of one.
Rotor entities carry their own availability, because a rotator controller that is switched off stops reporting while the switches carry on. Ten seconds of silence and they go unavailable on their own, rather than leaving a heading on screen for an antenna nobody is driving.
Entities go unavailable when the bridge loses either the broker or the switch, so
a stale dropdown never looks live. Commands are published with optimistic: false
— HA waits for the device to confirm rather than assuming the relay moved.
Credentials are read from $GH_MQTT_USERNAME / $GH_MQTT_PASSWORD as well as
flags, which keeps them out of your shell history and out of ps.
To run it as a service, see packaging/gh-mqtt.service.
greenheron/protocol.py |
framing and record parsing — pure, no I/O |
greenheron/client.py |
socket, state tracking, reconnect, keepalive |
greenheron/tui.py |
curses panel — antenna grid and rotator |
greenheron/gui.py |
GTK4 panel — antenna grid and rotator |
greenheron/compass.py |
the rotator dial, drawn with Cairo — no device knowledge |
greenheron/mqtt_bridge.py |
MQTT + Home Assistant discovery — switches and rotators |
packaging/ |
systemd user unit, desktop entry, environment files |
ghprobe.py |
hexdump whatever the device sends — the debugging oracle |
tools/ |
pcap extraction and protocol experiments |
NOTES.md |
the protocol, how each claim was established, and open questions |
python3 -m venv --system-site-packages .venv && .venv/bin/pip install pytest
.venv/bin/python -m pytest tests/
--system-site-packages is what lets the venv see PyGObject, which is a distro
package rather than a wheel. Without it the GTK tests skip and the rest still run.
Protocol fixtures are verbatim bytes captured off the device, not hand-written. Connection tests run against a local stand-in server, including records delivered one byte at a time and mid-session disconnects. No hardware needed.
Addresses in the docs use 192.0.2.10 — the RFC 5737 documentation range, not a real
host.
MIT. Not affiliated with or endorsed by Green Heron Engineering; the protocol was determined by observing traffic between their client and their device for interoperability.


