Skip to content

Repository files navigation

Kramer VS-44HN Remote Control

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.

tests latest release Python 3.8+ GPLv3 Windows, Linux, macOS

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.


Contents


Features

  • 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-run mode 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.server ship with Python, and the browser UI is one self-contained HTML file with no framework and no build step. pyserial is needed only for RS-232.

Know this before you touch the device

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

Three traps worth knowing

  1. 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.
  2. The rear RESET button is not the #FACTORY command. RESET clears only the IP parameters: routing, presets and EDID survive. The Protocol 3000 #FACTORY command wipes everything. Similar names, opposite consequences.
  3. 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 rear RESET button is the only path back — see First-time setup.

Download a ready-made build

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.

Windows will warn you, and here is why

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.exe
sha256sum -c SHA256SUMS.txt

Linux needs a recent enough glibc

The 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.

Where it keeps its settings

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.

Requirements

  • Python 3.8+
  • pyserialonly 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-tk for the system interpreter (a virtual environment cannot install Tkinter, it can only inherit it from the interpreter it was created from).

Installation

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.

Plain clone — enough for network use

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 probe

With a virtual environment — for RS-232, or to keep things isolated

Use 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 ports

If PowerShell refuses to run the activation script, allow signed local scripts for your user once:

Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

Linux / 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 ports

Leave the environment with deactivate. .venv/ is already listed in .gitignore.

Two things worth knowing:

  • The GUI needs Tkinter from the base interpreter. pip cannot provide it. On Debian/Ubuntu, sudo apt install python3-tk and then recreate the venv; on Windows and macOS the official installers already include it.
  • Serial access on Linux needs permissions. If /dev/ttyUSB0 gives Permission denied, add your user to the dialout group (sudo usermod -aG dialout $USER) and log out and back in. A venv changes nothing here.

Quick start

# 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.py

Command-line usage

Choosing a transport

Every 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 port

Start 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.

Global flags

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)

Diagnostics

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      # bounded

Pass --proto p2000 explicitly: protocol auto-detection would send an identify frame first, and the point of this command is a silent line.

Routing

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 routing

Presets

python 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 presets

Switching the device protocol

python 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 again

Raw bytes

python 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 automatically

Interactive shell

python 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

GUI

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.json

Note 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.

Staying in sync with the front panel

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.

Noticing that the matrix is gone, and getting it back

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.

Web service and browser UI

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 8080

It 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.

⚠️ Run one controller at a time

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.

Noticing that the matrix is gone

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.

Options

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.

Deliberate limits

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 STRING requires that token on every request, sent either as Authorization: Bearer STRING or 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. #FACTORY deliberately 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.

Running it as a service with Docker

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:latest

For 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/config

If 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-stopped makes 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.

Presets, and overwriting them

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:

  1. The endpoint does not work unless the service was started with --allow-preset-store. Without it, POST /api/preset/<n>/store answers 403 and 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.
  2. 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.
  3. 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.

Configuration, and where it lives

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:

  1. --config PATH
  2. the KRAMER_CONFIG environment variable
  3. a kramer_gui_config.json already 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
  4. 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.

API

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/events

Every 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.

First-time setup

1. Identify the device over serial

python kramer_vs44.py ports
python kramer_vs44.py --serial COM3 probe

probe 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.

2. Find the IP address, or restore the default

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/24

This 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.39

If 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.

3. Verify routing and store your presets

python kramer_vs44.py --tcp 192.168.1.39 shell

Set 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.

Protocol reference

Protocol 2000 — 4 bytes

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).

status semantics 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, and 0 means the output is disconnected. Measured: with output 3 disconnected, 05 80 83 81 replies 45 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.

Unsolicited notifications: measured, and asymmetric

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 switch command replies. Only the instruction number separates them, which is why Protocol2000._cmd() keeps the frames matching the instruction it sent and routes the rest to an on_notify callback. 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 60

Protocol 3000 — ASCII

Host 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.

Constants in the code

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 note on read timing

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.

Troubleshooting

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

Design notes

These are deliberate choices, not accidents.

  1. The GUI imports kramer_vs44, it does not reimplement the protocol. One single source of truth for the wire format.
  2. All I/O runs on a single worker thread. This is not a style preference: the 200 ms rate limit is enforced by the Transport object, 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.
  3. The two programs share the liveness policy, not the loop. kramer_vs44.LinkMonitor answers "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 synchronous call(fn, timeout) on the other. Liveness is anchored on Transport.last_rx — bytes actually received — rather than on commands that returned, because a read of a dead-but-open link "succeeds" with every value None, and a program that refreshes as often as it probes would then never probe at all.
  4. #FACTORY is 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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.

Known limitations

  • 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 the VID_REPLY_IS_IN_TO_OUT constant in kramer_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 to False.
  • 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 SerialTransport raises on open like anything else — but with no adapter here it has never actually been run.
  • EDID commands are not implemented (deliberately — see above).

Roadmap

  • 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 in tests/.
  • 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).

Contributing

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.py

See tests/README.md for the live integration tests, which need a matrix on the network and are therefore not part of CI.

Disclaimer

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.

License

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.

About

Python CLI and GUI to control a Kramer VS-44HN 4×4 HDMI matrix switcher over TCP/IP or RS-232. Implements both Protocol 2000 (binary) and Protocol 3000 (ASCII): routing, presets, network discovery, diagnostics. The device has no web interface — this replaces the front panel.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages