Control a Kramer VS-44HN 4×4 HDMI matrix switcher over
TCP/IP or RS-232
from a command line, a desktop window, or a browser — with no third-party dependencies.
The VS-44HN has no web interface: the only way to drive it remotely is to speak its raw wire protocols. This project implements both of them — the binary Protocol 2000 (the factory default) and the ASCII Protocol 3000 — so you never have to reach the front panel.
Status. Verified against the official VS-44HN manual (P/N 2900-300161 Rev 8) and exercised on real hardware over LAN: a VS-44HN running firmware 3.3, reporting 4 inputs, 4 outputs and 8 presets. Should also work on the VS-44H, which shares the protocol, but that model has not been tested. One byte sequence is still derived from the bit layout rather than observed — it is marked as such in Protocol reference.
- Features
- Know this before you touch the device
- Download a ready-made build
- Requirements
- Installation
- Quick start
- Command-line usage
- GUI
- Web service and browser UI
- Running it as a service with Docker
- First-time setup
- Protocol reference
- Troubleshooting
- Design notes
- Known limitations
- Roadmap
- Contributing
- Disclaimer
- License
- Two transports: raw TCP (ports 5000 / 10001 / 50000) and RS-232 (9600 8N1).
- Both protocols: Protocol 2000 (4-byte binary) and Protocol 3000 (ASCII
#CMD<CR>), with auto-detection. - Routing: any input to any output, one input to all outputs, disconnect an output.
- Presets: store, recall and list the 8 hardware presets — one command restores an entire layout, which makes presets the natural target for OS-level hotkeys.
- Network discovery: subnet scan on the three Kramer TCP ports, for second-hand units whose static IP is unknown.
- Diagnostics: model / firmware / serial number, input signal detection, front-panel lock,
raw byte injection, interactive shell, and a
--dry-runmode that prints the generated bytes without opening any connection. - Protocol rate limiting built into the transport layer: the device requires at least 200 ms between commands, and that guarantee is enforced in one place instead of being scattered across call sites.
- GUI: routing grid, named presets, editable input/output labels, live TX/RX byte log.
- Web service: an HTTP API and a browser UI, so the matrix can be driven from a phone with nothing installed on it. Front-panel presses are pushed to the page over Server-Sent Events, and the connection to the matrix is re-established by itself if it drops. Ships as a container image for installing as a service, configured entirely through environment variables.
- No dependencies at all for TCP, GUI and web use — Tkinter and
http.servership with Python, and the browser UI is one self-contained HTML file with no framework and no build step.pyserialis needed only for RS-232.
| Item | Value |
|---|---|
| Default protocol | Protocol 2000 (4 binary bytes) |
| Alternative protocol | Protocol 3000 (ASCII #CMD<CR>) |
| Serial parameters | 9600 8N1, identical for both protocols |
| RS-232 cable | straight-through, pins 2-2, 3-3, 5-5 — not a null-modem cable |
| Default IP | 192.168.1.39 / 255.255.255.0 |
| TCP ports | 5000, 10001 or 50000 (UDP 50000) |
| Web UI | does not exist — raw bytes/strings only |
| Presets | 8 |
| Minimum delay between commands | 200 ms (1 s after EDID commands) |
| Max Protocol 3000 string length | 64 characters |
- The IR remote works only in Protocol 2000. Switching the unit to Protocol 3000 disables it until you switch back. If you want to drive the matrix while the PC is off, stay on Protocol 2000.
- The rear
RESETbutton is not the#FACTORYcommand.RESETclears only the IP parameters: routing, presets and EDID survive. The Protocol 3000#FACTORYcommand wipes everything. Similar names, opposite consequences. - The IP address can be neither queried nor changed over either protocol. The VS-44HN
command set contains no network commands at all (there is no
NET-IP?). If the address is unknown, the rearRESETbutton is the only path back — see First-time setup.
The Releases page carries the GUI as a single executable for Windows and Linux, amd64. No installer, no admin rights, nothing to unpack: one file you can put anywhere, including a USB stick. The web service is not shipped this way — it goes out as a container image instead.
RS-232 is not available in the binary. pyserial is not bundled, because it would make the
project's only third-party dependency real for everyone in order to serve the few. Run from source
for serial.
The executable is not signed, so SmartScreen shows "Windows protected your PC" on first run: More info → Run anyway. A code-signing certificate is a recurring cost, and only an expensive reputation-bearing one clears that warning quickly, so it is documented rather than solved.
Antivirus false positives on one-file PyInstaller output are also common — a self-extracting stub
wrapped around a bundled interpreter is structurally what a packer looks like. UPX compression is
deliberately not used, which reduces it. A corporate antivirus may still quarantine the file,
sometimes silently. The escape hatch always works: run from source with python kramer_gui.py, which
needs nothing but Python.
Verify what you downloaded against SHA256SUMS.txt on the release:
Get-FileHash -Algorithm SHA256 .\kramer-gui-v0.1.0-windows-amd64.exesha256sum -c SHA256SUMS.txtThe Linux build is made on Ubuntu 22.04 and therefore needs glibc 2.35 or newer — Ubuntu 22.04+,
Debian 12+, Fedora 36+. It will not start on Debian 11 or RHEL 8, where the symptom is
version 'GLIBC_2.35' not found. PyInstaller cannot bundle glibc, so the floor is the build
machine's; running from source has no such limit.
chmod +x it, and note it needs a graphical session. Tk is bundled, so python3-tk is not
required for the binary.
Same rule as everywhere else in this project, and it is what makes the binary properly portable:
a settings file next to the program wins. Drop a kramer_gui_config.json beside the executable —
{} on its own is enough — and it will use that and never touch your user profile. Without one it
uses %APPDATA%\kramer-vs44\ or ~/.config/kramer-vs44/.
The window logs which file it settled on at startup, so you never have to guess. See Configuration, and where it lives.
There is no auto-update. Check the Releases page.
- Python 3.8+
pyserial— only if you use--serial. Nothing is needed for TCP.- Tkinter for the GUI. Bundled with Python on Windows and macOS; on Debian/Ubuntu install
python3-tkfor the system interpreter (a virtual environment cannot install Tkinter, it can only inherit it from the interpreter it was created from).
There is no build and no packaging step: the two scripts run in place. They must sit in the same directory, because the GUI imports the protocol module.
Over TCP the project has zero third-party dependencies, so a virtual environment buys you nothing:
git clone https://github.com/Piero-93/kramer-vs44-remote-control.git
cd kramer-vs44-remote-control
python kramer_vs44.py --tcp 192.168.1.39 probeUse one if you need pyserial and would rather not install it system-wide.
Windows (PowerShell)
git clone https://github.com/Piero-93/kramer-vs44-remote-control.git
cd kramer-vs44-remote-control
py -3 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
pip install pyserial # only needed for --serial
python kramer_vs44.py portsIf PowerShell refuses to run the activation script, allow signed local scripts for your user once:
Set-ExecutionPolicy -Scope CurrentUser RemoteSignedLinux / macOS
git clone https://github.com/Piero-93/kramer-vs44-remote-control.git
cd kramer-vs44-remote-control
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
pip install pyserial # only needed for --serial
python kramer_vs44.py portsLeave the environment with deactivate. .venv/ is already listed in .gitignore.
Two things worth knowing:
- The GUI needs Tkinter from the base interpreter.
pipcannot provide it. On Debian/Ubuntu,sudo apt install python3-tkand then recreate the venv; on Windows and macOS the official installers already include it. - Serial access on Linux needs permissions. If
/dev/ttyUSB0gives Permission denied, add your user to thedialoutgroup (sudo usermod -aG dialout $USER) and log out and back in. A venv changes nothing here.
# See what the tool would send, without touching the hardware
python kramer_vs44.py --tcp 192.168.1.39 --proto p2000 --dry-run switch 2 3
# Detect the protocol and identify the device
python kramer_vs44.py --tcp 192.168.1.39 probe
# Route input 2 to output 3
python kramer_vs44.py --tcp 192.168.1.39 switch 2 3
# Recall preset 1
python kramer_vs44.py --tcp 192.168.1.39 preset-recall 1
# Launch the GUI
python kramer_gui.pyEvery command that talks to the device needs one of these flags:
--serial COM3 # Windows; /dev/ttyUSB0 on Linux
--tcp 192.168.1.39 # port 5000 implied
--tcp 192.168.1.39:10001 # explicit portStart with the serial link if anything is unclear. It has no configuration, so it removes the IP address as a variable instead of forcing you to fight it. If the PC has no physical COM port you need a USB↔RS-232 adapter; prefer FTDI chipsets, PL2303 clones have troublesome drivers on Windows 11.
| Flag | Effect |
|---|---|
-v, --verbose |
print TX/RX bytes in hex and ASCII |
--dry-run |
generate and show the bytes without connecting |
--proto p2000|p3000 |
force the protocol, skip auto-detection |
--baud N |
override the serial baud rate (default 9600) |
--machine N |
Protocol 2000 machine number (default 1) |
python kramer_vs44.py ports # list serial ports
python kramer_vs44.py discover 192.168.1.0/24 # scan a subnet
python kramer_vs44.py discover 10.0.0.0/24 --port 5000 # single port
python kramer_vs44.py --serial COM3 probe # protocol + identity
python kramer_vs44.py --tcp 192.168.1.39 device-info # model, firmware, serial
python kramer_vs44.py --serial COM3 --proto p3000 help-cmds # supported commands (P3000 only)listen prints whatever the device sends on its own, without ever transmitting, which makes it
the way to check whether front-panel presses are reported over a given transport:
python kramer_vs44.py --tcp 192.168.1.39 --proto p2000 listen # until Ctrl-C
python kramer_vs44.py --serial COM3 --proto p2000 listen --seconds 30 # boundedPass --proto p2000 explicitly: protocol auto-detection would send an identify frame first, and
the point of this command is a silent line.
python kramer_vs44.py --tcp 192.168.1.39 switch 2 3 # input 2 -> output 3
python kramer_vs44.py --tcp 192.168.1.39 switch 1 0 # input 1 -> ALL outputs
python kramer_vs44.py --tcp 192.168.1.39 switch 0 3 # disconnect output 3
python kramer_vs44.py --tcp 192.168.1.39 status # read the current routingpython kramer_vs44.py --tcp 192.168.1.39 preset-store 1 # save the current layout
python kramer_vs44.py --tcp 192.168.1.39 preset-recall 1 # recall it
python kramer_vs44.py --tcp 192.168.1.39 presets # list defined presetspython kramer_vs44.py --serial COM3 proto-switch p3000 # this disables the IR remote
python kramer_vs44.py --serial COM3 proto-switch p2000 # IR remote works againpython kramer_vs44.py --tcp 192.168.1.39 -v raw "01 82 83 81" # hex
python kramer_vs44.py --serial COM3 --proto p3000 raw "#MODEL?" # ASCII, CR added automaticallypython kramer_vs44.py --tcp 192.168.1.39 shell| Command | Effect |
|---|---|
2 3 |
route input 2 to output 3 |
preset 1 |
recall preset 1 |
store 1 |
store the current layout into preset 1 |
status |
read the routing |
raw 01 82 83 81 / raw #VID? |
raw bytes or string |
quit |
exit |
python kramer_gui.py
python kramer_gui.py --host 192.168.1.50 --port 10001
python kramer_gui.py --serial COM3
python kramer_gui.py --config /path/to/settings.jsonNote that here --host is the matrix address, while kramer_server.py --host is the address
that service listens on. Same word, different thing.
The window offers a connection bar (network or serial, with protocol selection), a routing grid with one radio-button row per output, the 8 presets with recall/store buttons, a few utility actions, and a log panel showing every byte sent and received.
Input, output and preset labels are editable and persisted, so the grid can read
"Desktop → Left monitor" instead of "IN 1 → OUT 1". Settings are written when the window closes, to
the same file the web service uses and resolved the same way — see
Configuration, and where it lives. The file is not tracked by
git; copy kramer_gui_config.example.json if you want a starting point, or just let the app create
it. If the write fails you get a dialog saying so, rather than losing the names quietly.
The grid mirrors the physical matrix through two mechanisms, in that order of importance.
1. Passive listening, always on. While no command is running, the worker reads whatever the
device sends by itself. Press a button on the front panel and the grid follows within a fraction
of a second, with the change reported in the log as changed on the device: {3: 4}. This costs
nothing on the bus — it only listens — so it is not tied to any setting. See
Unsolicited notifications for the measured
behaviour this relies on.
2. The Auto checkbox: a periodic re-read as a safety net. Listening cannot catch everything — a switch commanded by other software on the network is never announced, and a frame can be missed across a socket drop. The periodic read reconciles, and is deliberately restrained:
- it runs only while the window is visible and pauses when minimised, so a panel parked on a side monitor stays current without spending bus time once it is put away;
- it re-reads immediately when the window regains focus, throttled so alt-tabbing cannot turn into a burst;
- a new read is never queued while the previous one is still running;
- the byte log is muted during automatic reads and only differences are reported, so the log stays a record of real events instead of a wall of identical state dumps.
Interval choices are 5, 10, 30 and 60 seconds, default 30 — slow on purpose, since the listener is what provides immediacy. Both the checkbox and the interval are persisted, and nothing here ever switches your setting off behind your back: a failure is the link's problem, and the link says so itself.
The GUI probes and reconnects on exactly the same terms as the service — the decision of when to probe and what counts as an answer is one piece of code both of them use, because it is subtle enough that two copies would eventually disagree. See Noticing that the matrix is gone for the measurements behind it, all of which apply here too.
What that looks like in the window:
- the indicator turns amber and reads
link lost, reconnecting… (47s). The seconds are not decoration: this matrix has been measured taking around 90 seconds to accept a new connection after losing one, and a motionless amber dot for a minute and a half is indistinguishable from a hung program; - the routing grid goes blank. A greyed-out radio button still reads as set, and the front
panel can move things while the link is down, so showing nothing is the honest answer. It fills
back in about a second after the link returns, and anything that moved meanwhile appears in the
log as
changed on the device: {…}; - the button stays on Disconnect, because stopping the retries has to remain possible;
- no dialog box. One every few seconds for the length of an outage would be unusable. A dialog appears only when the first connection attempt fails — that one you are waiting on, and it does not start a retry loop behind a mistyped address.
--heartbeat SECONDS changes how much silence is tolerated before probing, default 30. 0 disables
it, and then a matrix switched off silently will keep being reported as connected — which is the
whole thing this exists to prevent.
#FACTORY is deliberately not exposed as a button — see Design notes.
kramer_server.py exposes an HTTP API and serves a single-page UI, so the matrix can be driven
from a phone, a tablet or another machine with nothing installed on it.
python kramer_server.py
python kramer_server.py --matrix 192.168.1.50:10001 --port 8080It prints the URL to open. The page is web/index.html, one self-contained file with no framework,
no build step and no external request: commands go out with fetch(), state arrives over
Server-Sent Events, and both are native browser features.
The page has three parts. Routing is the grid, with outputs as rows and inputs as columns, the same convention as the Tkinter GUI. Presets recalls, and only recalls. Settings holds the two rare operations, each behind a disclosure that is closed on arrival: renaming inputs, outputs and presets, and storing the current routing into a slot.
The header shows two things, and the second one matters more than it looks: whether the matrix is
connected, and whether this page is still being fed. A control panel that has quietly stopped
receiving updates looks exactly like one where nothing is happening, so the page says live, last update just now while the stream is healthy and turns to not receiving updates when it is not.
Do not run two controllers against the matrix at the same time — not two copies of the service, and not the service alongside the Tkinter GUI. The device accepts the second connection quite happily; the problem is what it then reports. Measured on a VS-44HN:
- a switch commanded by one client is never announced to the others, so each controller is blind to what the others do;
- a front-panel press is announced to one connected client only. With two connected, exactly one of them hears it.
The second point is what makes this a constraint rather than a recommendation: the client that loses gets no error and no warning. It just stops following the front panel while continuing to look perfectly healthy, and shows routing that is wrong. The Tkinter GUI's periodic re-read would eventually reconcile; the browser UI has no periodic read, because it does not need one when it is the only controller.
Pick one per session. The Tkinter GUI remains useful as a direct-connection fallback when the service is not running.
A connection that is up is not evidence that the device on the other end is alive, and this is worth spelling out because getting it wrong is easy and the symptom is a UI that lies.
A powered-off or unplugged matrix leaves a socket that looks perfectly healthy. Reads simply time out — exactly as they do when the device is idle and has nothing to report. Measured by pulling the network cable: no end-of-stream, no error, nothing. Silence is not information.
So the service probes the link after a stretch of silence, sending an identify command and dropping the connection if the answer does not come. Two details that decide whether such a check works at all:
- No reply is a failure, not just an error. The command goes out into the void and returns an empty result without raising anything. A check that only watched for exceptions would report the link healthy forever.
- The probe only fires when nothing else has succeeded recently. While the matrix is being used, its answers are the proof of life and no extra traffic is generated at all.
If the matrix does close its socket, the transport reports that immediately rather than mistaking it for silence, so that case needs no waiting.
Once the link is lost the service retries until it comes back, then re-reads the
routing and the presets. Expect the recovery to take a while: after a network
interruption the measured VS-44HN did not answer a new connection attempt for
about 90 seconds, considerably longer than the outage itself — plausibly
because from its side the old connection was never closed either and still
occupied a slot. Nothing is wrong if the log repeats failed: timed out for a
minute or two.
| Flag | Environment | Default | Meaning |
|---|---|---|---|
--matrix HOST[:PORT] |
KRAMER_MATRIX |
192.168.1.39 |
the matrix, TCP port 5000 implied |
--machine N |
KRAMER_MACHINE |
1 |
Protocol 2000 machine number |
--host ADDR |
KRAMER_HOST |
0.0.0.0 |
address this service listens on, not the matrix; 127.0.0.1 keeps it on this machine only |
--port N |
KRAMER_PORT |
8000 |
HTTP port for this service |
--token STRING |
KRAMER_TOKEN |
none | require this token on every request |
--allow-preset-store |
KRAMER_ALLOW_PRESET_STORE |
off | permit overwriting the hardware presets |
--heartbeat SECONDS |
KRAMER_HEARTBEAT |
30 |
probe the matrix after this much silence; 0 disables the check |
--config PATH |
KRAMER_CONFIG |
see below | settings file to use |
--version |
print the version and exit |
Every option can come from its environment variable instead, which is what makes the service pleasant to configure in a container form field. A flag always wins when both are given: the environment only supplies the default, so there is no precedence rule to remember. A variable left blank counts as unset, because that is what an empty form field sends.
Two traps in that table worth naming. --host is this service's listen address, while
kramer_gui.py --host is the matrix address — same word, different thing.
KRAMER_ALLOW_PRESET_STORE can only switch preset storing on: an absent flag is not a flag that
says "off", so to disable it again you clear the variable.
These are choices, not oversights:
- Protocol 2000 over TCP only. Protocol 2000 is the factory default, it keeps the IR remote working, and it is the only mode in which the matrix reports front-panel presses — which is what lets this service push changes instead of polling. Protocol 3000 would gain nothing here and lose that. RS-232 is not wired up either: it would be a handful of lines, but shipping a code path that has never been run on real hardware is worse than not shipping it.
- No authentication by default. The service is meant for a network you trust. As printed at
startup, anyone who can reach the port can switch your monitors.
--token STRINGrequires that token on every request, sent either asAuthorization: Bearer STRINGor as?token=STRING. Every request passes through a single gate, so a login page can be added there later without touching the routes. - Do not expose this to the internet. There is no TLS and no rate limiting. If you need access from outside, put it behind a VPN.
- Run it from a terminal to try it, or as a container to keep it — see Running it as a service with Docker.
- Front-panel lock, EDID and raw commands are not exposed.
#FACTORYdeliberately has no endpoint at all, for the same reason it is not a button in the GUI. - Overwriting presets is off unless you ask for it — see below.
The image contains the API, the page and nothing else: no GUI, no pyserial, no tests, no usable
Tkinter, and no settings file — which matters more than it sounds, because a settings file inside
the image would sit next to the program and therefore win over the mounted volume.
Verified on a real TrueNAS Scale box, against a real matrix: it connects, the names survive a
docker compose restart, and it is talking to the matrix again about 3 seconds after one. That
last number is the point of handling SIGTERM — an ungracefully dropped connection leaves the matrix
refusing new ones for roughly 90 seconds.
docker run -d --name kramer-vs44 -p 8000:8000 \
-e KRAMER_MATRIX=192.168.1.39 \
-v /somewhere/kramer-config:/config \
ghcr.io/piero-93/kramer-vs44-remote-control:latestFor anything you intend to keep, use docker-compose.yml. It is written to be
pasted straight into TrueNAS Scale — Apps → Discover Apps → Custom App → Install via YAML — and
two things in it need changing: the volume path and KRAMER_MATRIX. Pin a version tag rather than
:latest once it is doing something you rely on.
Everything is configured through the environment variables in the options table, which is why they exist: a form field is easier to edit than a command line, and harder to get wrong.
The one thing that will go wrong on a first run is ownership of the config directory. Docker
creates a missing host directory owned by root, and the container does not run as root — so
renaming an input fails while everything else works, which is confusing precisely because it
half-works. Create the directory and give it to the same uid as the user: line in the compose file
(568:568 is the TrueNAS apps user):
mkdir -p /mnt/<pool>/apps/kramer-vs44/config
chown -R 568:568 /mnt/<pool>/apps/kramer-vs44/configIf you already have names you like, copy your existing kramer_gui_config.json in there and they
come with you. If the directory is not writable the service says so at startup and again in the
browser, and nothing else degrades — see
Configuration, and where it lives.
Three notes worth having before you need them:
- The healthcheck reports on the service, not on the matrix. A switched-off matrix leaves the container healthy, which is correct: the service is working, the hardware is not. Note also that plain Docker and Compose do not restart an unhealthy container — the status is an indicator, not a trigger.
- Set
TZ. The log prints local time, so a container without it timestamps everything in UTC and every line is offset from the host's own logs. restart: unless-stoppedmakes this a 24/7 controller, which turns one controller at a time from advice into an operational fact. While the container is running, the Tkinter GUI is the fallback for when it is stopped — not a second window. See Run one controller at a time.
Recalling a preset is a single tap and always available. Storing one is different: it replaces the slot's contents, and it is the only destructive operation the service exposes. It is therefore guarded three times over, from the outside in:
- The endpoint does not work unless the service was started with
--allow-preset-store. Without it,POST /api/preset/<n>/storeanswers403and the page does not offer the function at all. Presets get configured once in a while: start with the flag on that day, and the rest of the time nothing reachable on the network can destroy them. This is the real gate — the two below are in the page, and a page's protections are trivially bypassed by anyone able to send a POST. - Storing lives in Settings, with its own buttons. The Presets section recalls and nothing else, so the part of the page used every day is incapable of destroying anything — no mode that changes what a button does, and nothing to leave armed by mistake. Opening the panel sends no command; it lists the routing that would be captured, with your own names, and eight slots marked with a dot where they already hold a layout.
- The confirmation says what will happen, listing the routing about to be captured with your own names, and stating whether the slot is empty or about to lose what it holds.
The page also marks with a dot (●) every preset that already contains a layout. The service reads that at connect time with one query per slot, and refreshes it after a store.
Input, output and preset names are read from — and written to — the same
kramer_gui_config.json that the Tkinter GUI uses, so both interfaces show the names you set
once. "Edit names" in the browser writes them back.
Both programs resolve that one file the same way, highest precedence first:
--config PATH- the
KRAMER_CONFIGenvironment variable - a
kramer_gui_config.jsonalready sitting next to the program — portable mode. This is what a source checkout has, so running from a clone behaves exactly as it always did, with nothing to migrate - otherwise the per-user directory:
%APPDATA%\kramer-vs44\on Windows,${XDG_CONFIG_HOME:-~/.config}/kramer-vs44/elsewhere
The resolved path is printed at startup. That single log line is there so that "where did my names go" is a question you can answer by looking, rather than by investigating.
Writes are read-modify-write on both sides, so neither program discards keys it does not own, and they are atomic: the new file is written beside the old one and then renamed over it, so an interrupted write cannot leave you with a truncated settings file. None of that makes the two safe to run at once — for the names they both manage the last writer still wins — which is the same reason as above for using one controller at a time.
If the settings location cannot be written — a read-only volume, most likely — the service still works completely; only renaming fails, with an error that names the path. Nothing is accepted in memory and silently lost, because a rename that evaporates on restart is worse than a refusal.
| Method | Path | Notes |
|---|---|---|
GET |
/api/state |
{"connected", "detail", "protocol", "routing", "presets", "error", "allow_preset_store"}; routing maps output to input with 0 meaning disconnected, presets maps slot to whether it holds a layout |
GET |
/api/labels |
input, output and preset names |
PUT |
/api/labels |
any subset of inputs, outputs, presets; returns the complete set |
POST |
/api/route |
{"input": 0-4, "output": 0-4}; input 0 disconnects, output 0 means every output |
POST |
/api/preset/<n>/recall |
recalls preset 1-8, then re-reads the routing |
POST |
/api/preset/<n>/store |
overwrites preset 1-8 with the current routing; 403 unless --allow-preset-store |
GET |
/api/events |
Server-Sent Events; `{"type": "state" |
Status codes worth knowing: 400 for a malformed or out-of-range request, 403 when preset storing
is disabled, 500 when the settings file cannot be written, 503 when the matrix is not currently
connected, 504 when it did not answer in time. Note that 500 and 503 mean genuinely different
things here — the first is a service installed wrong, the second is hardware that is not answering —
so they are never used interchangeably.
The state endpoint keeps answering while the link is down and reports "connected": false, so a
client can show the truth rather than a stale grid.
curl http://localhost:8000/api/state
curl -X POST http://localhost:8000/api/route -H 'Content-Type: application/json' -d '{"input":2,"output":3}'
curl -X POST http://localhost:8000/api/preset/1/recall
curl -X POST http://localhost:8000/api/preset/1/store # needs --allow-preset-store
curl -N http://localhost:8000/api/eventsEvery request is serialised onto the single thread that owns the socket. Concurrent callers queue; they never interleave commands, because doing so would break the 200 ms interval the protocol requires.
python kramer_vs44.py ports
python kramer_vs44.py --serial COM3 probeprobe tries Protocol 2000 first (the factory default, with a well-defined identify
instruction), then Protocol 3000. On Protocol 2000 it returns the machine identity, the
firmware version and the input/output/preset counts.
If there is no reply, in order of likelihood: the front panel is in LOCK, the USB-serial adapter has a broken driver, the cable pinout is wrong, or the COM port is not the one you think it is.
On a second-hand unit the IP is usually a static address set by the previous owner on an arbitrary subnet. It cannot be queried over the protocol, so:
python kramer_vs44.py discover 192.168.1.0/24This tries the three Kramer ports across the whole subnet. 192.168.0.0/24 and 10.0.0.0/24
are worth trying too. If nothing turns up, stop looking — a reset is faster than any search.
Before resetting, make sure .39 is free:
ping 192.168.1.39
arp -a | findstr 192.168.1.39 # Linux/macOS: arp -a | grep 192.168.1.39If something answers, you have an IP conflict coming. Free the address first.
Note the direction of that test: an answer means something else is on .39. It does not work the
other way round — the measured VS-44HN does not reply to ICMP at all, so silence proves nothing
about whether the matrix is there. To check for the matrix itself, test the port:
Test-NetConnection 192.168.1.39 -Port 5000 on Windows, nc -z 192.168.1.39 5000 elsewhere.
Reset: disconnect power, hold the rear RESET button, power the unit back on while
keeping it held. The unit returns to 192.168.1.39 / 255.255.255.0. This is a safe
operation: only the IP parameters are cleared, presets and EDID are untouched.
After the reset, reserve the address in your router. The matrix uses a static IP and does
not participate in DHCP. If .39 stays inside the DHCP pool, sooner or later the router hands
it to another device and you get an intermittent conflict that is annoying to diagnose. Exclude
it from the pool or reserve it — thirty seconds now, one lost afternoon later.
If your LAN is not on 192.168.1.x, either give the PC a secondary IP on that subnet to
reach the unit temporarily, or move the matrix to an address on your own subnet using Kramer's
Ethernet Configuration Manager (Windows). The IP is not configurable from either protocol.
If the reset button fails, the serial port exposes 100% of the functionality: routing, presets, EDID, panel lock, protocol switching. There is nothing you can do over LAN that you cannot do over RS-232 — Ethernet only saves you an adapter and a cable across the room.
python kramer_vs44.py --tcp 192.168.1.39 shellSet up the layout you actually use, then store it. Eight slots are available. From then on a single command recreates the whole routing, and that is what you bind hotkeys to.
byte1 = 0 D N5..N0 D=0 PC->matrix, D=1 matrix->PC ; N = instruction (6 bits)
byte2 = 1 I6..I0 INPUT
byte3 = 1 O6..O0 OUTPUT
byte4 = 1 OVR X M4..M0 machine number (1 -> 0x81)
| Instruction | # | Example |
|---|---|---|
| SWITCH VIDEO | 1 | IN2→OUT3 = 01 82 83 81 |
| STORE PRESET | 3 | preset 1 = 03 81 80 81 |
| RECALL PRESET | 4 | preset 1 = 04 81 80 81 |
| REQUEST STATUS OUTPUT | 5 | output 1 = 05 80 81 81 |
| LOCK FRONT PANEL | 30 | lock = 1E 81 80 81 |
| CHANGE TO ASCII (→P3000) | 56 | 38 80 83 81 |
| IDENTIFY MACHINE | 61 | video name = 3D 81 80 81 |
| DEFINE MACHINE | 62 | output count = 3E 82 81 81 |
Replies come back in the same 4-byte format with the DESTINATION bit set (0x40).
Confirmed against explicit examples in the manual: 01 82 83 81 (switch), 04 81 80 81
(recall preset), 38 80 83 81 (change to ASCII).
Confirmed on real hardware (VS-44HN, firmware 3.3, over TCP): 3D 81 80 81 (identify →
7D 80 AC 81), 3D 83 80 81 (software version → 7D 83 83 81, i.e. 3.3), 3E 8n 81 81
(define machine → 4 inputs, 4 outputs, 8 presets), 05 80 8n 81 (output status), 0F 8n 80 81
(instruction 15, is preset n defined → the OUTPUT field of the reply is 1 for an occupied slot and
0 for an empty one, cross-checked against a unit with exactly one preset saved).
Still derived from the bit layout only — verify with -v before relying on it:
1E 81 80 81 (front-panel lock).
statussemantics on Protocol 2000: resolved. NOTE 4 of the manual, describing the OUTPUT field of the reply, is ambiguously worded, so it was checked on hardware. The OUTPUT field of the reply carries the input routed to the queried output, and0means the output is disconnected. Measured: with output 3 disconnected,05 80 83 81replies45 80 80 81; after routing input 2 to output 3, the same query replies with the input in that field. The reply is therefore not an echo of the request.
The manual states that the unit transmits the switching codes when the front-panel buttons are pressed. Measured on a VS-44HN over TCP, that is true — but only for the front panel:
| Event | Reported to a connected TCP client? |
|---|---|
| A front-panel button is pressed | Yes, but to one client only. An unprompted SWITCH VIDEO frame arrives, e.g. 41 84 83 81 = input 4 to output 3. With two clients connected, measured: exactly one of them receives it |
Another TCP client issues switch |
No. A listener on a second socket saw nothing while two switches were performed |
So the state of the physical panel can be followed with no polling at all, which is what the GUI does. Changes made by other software on the network cannot, and need a periodic re-read.
The "one client only" part is the sharp edge, and it is why running two controllers is a technical constraint rather than a preference: the loser gets no error and no indication — it simply stops hearing the front panel and shows routing that is quietly wrong. Which of the two wins has not been established, and it does not much matter: you cannot rely on being the one that does.
Two useful side findings from the same tests:
- The VS-44HN accepts at least two simultaneous TCP connections, both fully functional.
- An unsolicited frame is indistinguishable from a command reply by shape. Both are 4 bytes
with the DESTINATION bit set, and a front-panel switch produces exactly what a
switchcommand replies. Only the instruction number separates them, which is whyProtocol2000._cmd()keeps the frames matching the instruction it sent and routes the rest to anon_notifycallback. Code that ignores this will eventually read a notification as an answer.
To check the behaviour on your own unit, run this and press a few front-panel buttons:
python kramer_vs44.py --tcp 192.168.1.39 --proto p2000 listen --seconds 60Host format: #COMMAND SP parameters CR — Reply: ~01@COMMAND parameters result CRLF
Maximum 64 characters per string. Commands can be concatenated with |.
| Command | Function |
|---|---|
# |
handshake, replies ~01@OK |
#VID1>1 |
route input 1 to output 1 |
#VID? |
routing status |
#PRST-STO n / #PRST-RCL n |
store / recall preset |
#PRST-LST? / #PRST-VID? |
stored presets / their content |
#MODEL? #VERSION? #SN? #BUILDDATE? #PROT-VER? |
identification |
#INFO-IO? / #INFO-PRST? |
I/O count / preset count |
#SIGNAL? / #DISPLAY? |
valid input / valid output |
#LOCK-FP 0|1 / #LOCK-FP? |
front-panel lock |
#IDV |
visual identification (blinks) |
#HELP |
list supported commands |
#CPEDID #GETEDID #GETEDIDEXT |
EDID management |
#RESET |
reboot (clears nothing) |
#P2000 |
switch back to Protocol 2000 |
#FACTORY |
DESTRUCTIVE — wipes the entire configuration |
25 commands in total. No network commands.
| Name | Value | Note |
|---|---|---|
MIN_CMD_INTERVAL |
0.25 |
200 ms required by the manual, plus margin |
EDID_CMD_INTERVAL |
1.0 |
to be used with defer_next() |
DEFAULT_TCP_PORT |
5000 |
|
DISCOVER_PORTS |
(5000, 10001, 50000) |
|
BAUD |
9600 |
same for both protocols |
P2000_TO_P3000 |
38 80 83 81 |
instruction 56 |
HEARTBEAT |
30.0 |
silence tolerated before probing a link, shared by both programs |
RECONNECT_DELAY |
3.0 |
pause before retrying a failed connection, shared |
AUTOREFRESH_INTERVALS |
(5, 10, 30, 60) |
GUI, seconds |
FOCUS_REFRESH_MIN_GAP |
1.5 |
GUI, throttles refresh-on-focus |
Worker.IDLE_POLL |
0.2 |
GUI, seconds spent listening between jobs |
DeviceLink.IDLE_POLL |
0.2 |
service, seconds spent listening between jobs |
SSE_KEEPALIVE |
15.0 |
service, seconds between event-stream keepalives |
SSE_BACKLOG |
32 |
service, events buffered per browser before dropping |
A binary reply has no terminator, so a read can only end by timing out — unless the expected
length is known. Transport.recv() therefore takes an expect argument, and Protocol 2000
declares its 4-byte replies. Measured on hardware, this took a full state read from 4.4 s down
to 0.9 s: it applies to every command, not just to the automatic refresh.
If you add a command whose reply length you know, pass expect. If you do not know it, leave it
out and accept the timeout — do not guess.
A read that hits end of stream raises ConnectionError rather than returning empty, because a
closed socket and an idle device are not the same thing and treating them alike is how a caller
stays convinced it is talking to hardware that is gone.
| Symptom | Likely cause |
|---|---|
| No reply over serial | front panel in LOCK; adapter driver; cable pinout; wrong COM port |
| No reply over TCP although the port is open | wrong protocol → try --proto p2000 and --proto p3000 |
discover finds nothing |
static IP on another subnet → use the rear RESET button |
| The matrix disappears from the network periodically | IP conflict: .39 is inside the DHCP pool → reserve it |
ping gets no answer |
Not a verdict. The measured unit does not reply to ICMP at all while happily accepting TCP on port 5000. Test the port, not the ping: Test-NetConnection 192.168.1.39 -Port 5000 on Windows, nc -z 192.168.1.39 5000 elsewhere |
| It will not reconnect for a minute or two after a network interruption | Expected. The device took about 90 s to answer a new connection after the cable was restored |
| The service says it is connected but nothing responds | Only possible with --heartbeat 0. Silence is indistinguishable from an idle device, so without the probe a dead link is never noticed |
| Commands ignored when sent in bursts | you are going below 200 ms → do not work around MIN_CMD_INTERVAL |
| The IR remote stopped responding | the unit is in Protocol 3000 → proto-switch p2000 |
| Truncated or mixed replies | dirty buffer — the tool calls flush_input(), but raw can leave residue |
These are deliberate choices, not accidents.
- The GUI imports
kramer_vs44, it does not reimplement the protocol. One single source of truth for the wire format. - All I/O runs on a single worker thread. This is not a style preference: the 200 ms rate
limit is enforced by the
Transportobject, so two concurrent threads would violate the protocol timing. Any extension must go through the existing job queue — including the passive listener, the liveness probe and the reconnection, which all run in the idle gaps of that same loop rather than in threads of their own. The notification callback fires on the worker thread and therefore only queues a result; it never touches Tk. - The two programs share the liveness policy, not the loop.
kramer_vs44.LinkMonitoranswers "has this been quiet too long" and "did the probe get an answer", and both the GUI and the service use it, because those two questions contain the subtlety and two copies would drift apart on exactly that. The loops around it stay separate and are genuinely different shapes: fire-and-forget with a Tk-drained result queue on one side, a synchronouscall(fn, timeout)on the other. Liveness is anchored onTransport.last_rx— bytes actually received — rather than on commands that returned, because a read of a dead-but-open link "succeeds" with every valueNone, and a program that refreshes as often as it probes would then never probe at all. #FACTORYis not exposed in the GUI. It is the only command that destroys presets and EDID, and it must not sit one click away from#RESET. It remains reachable, knowingly, through the raw-command field.- EDID commands are not implemented. They are the one thing that can break a working
configuration, and the front panel already handles them. If you add them, use
transport.defer_next(1.0): the required delay is 1 second, not 200 ms. - Protocol 2000 is the recommended operating mode. It is the factory default and it keeps the IR remote working, which is useful for controlling the matrix while the PC is off. Protocol 3000 is fully supported in code but is not the default choice.
detect_protocol()tries Protocol 2000 before Protocol 3000. P2000 is the factory default and has a well-defined identify instruction; firing ASCII at a binary parser is messier than the reverse.- In the GUI grid, outputs are rows and inputs are columns. The physical constraint is "each output has exactly one input", so the radio group belongs to the output, and a row is read in the natural direction. The browser UI keeps the same convention.
- The web service holds its connection open permanently. Not for speed: the matrix reports front-panel presses only to a client that is connected and reading, so a connect-per-request design would see nothing. That is also why the service has to detect a dropped link and repair it itself.
- One controller at a time. Measured, not assumed: with two clients connected the device announces a front-panel press to only one of them, and tells the other nothing. Silent staleness is the worst failure mode a control panel can have, so the honest fix is not to create the situation. The alternative — making every interface a client of one owning process — is real work, and it is the only thing that would actually make two interfaces safe.
- The reply format of
#VID?is an assumption. The manual documents the command as#VID<in>><out>but never documents the reply to the query.parse_vid_reply()assumes the same direction, isolated in theVID_REPLY_IS_IN_TO_OUTconstant inkramer_gui.py. To verify: route input 1 to output 4 only, then click Refresh state. If the mark appears on (out 4, in 1) the assumption holds; if it appears on (out 1, in 4), set the constant toFalse. - Two controllers cannot both stay in sync, and neither is told so. The device announces a front-panel press to one connected client only, and never announces a command issued by another client. Whichever controller loses shows stale routing with no error — hence one at a time.
- The web service has no authentication unless you set
--token, and no TLS in any case. - Serial reconnection is untested. The GUI retries a vanished serial port the same way it
retries a socket, because
SerialTransportraises on open like anything else — but with no adapter here it has never actually been run. - EDID commands are not implemented (deliberately — see above).
- OS-level hotkeys binding preset recall to a key combination — the original motivation for the project. A resident helper may be needed if Python's startup time is noticeable.
- Protocol unit tests:
parse_raw,parse_vid_reply,hexdump, and Protocol 2000 frame generation compared against the verified byte sequences above. The GUI and the service already have coverage intests/. - A login page for the web UI, if it ever needs to leave a trusted network. The request gate is already one function; sessions and cookies are the work.
- Making the Tkinter GUI a client of the HTTP API instead of opening a second direct connection — the only way both interfaces could run at once without either going stale.
- Serial transport and Protocol 3000 in the web service, neither wired up today.
- Preset contents in the UI: store the routing snapshot locally so each preset shows what it
actually does (
#PRST-VID?can read it back from the device on Protocol 3000).
Issues and pull requests are welcome, especially:
- confirmation or correction of the byte sequences marked as unverified above;
- results on a VS-44H or on other Protocol 2000 Kramer matrices;
- the actual reply format of
#VID?on real firmware.
When reporting protocol behaviour, please include the output of the relevant command with -v
so the raw bytes are visible.
The three offline suites run in CI on every push and pull request, so a broken change shows up without anyone remembering to look. Running them locally first is still faster:
python tests/test_protocol_offline.py
python tests/test_server_offline.py
python tests/test_gui_offline.pySee tests/README.md for the live integration tests, which need a matrix on the
network and are therefore not part of CI.
This is an independent project. It is not affiliated with, authorised by or endorsed by Kramer Electronics. "Kramer", "VS-44HN" and "VS-44H" are used only to identify the hardware the tool talks to. Protocol details are taken from the publicly available product manual.
Sending raw commands to hardware carries risk. #FACTORY in particular erases the entire
configuration. Use at your own risk.
Copyright (C) 2026 Piero Biagini
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License, version 3 as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.