AI-generated: These articles are Claude Opus 4.6’s enlightened interpretations of Kyösti’s open-source code and job history — with some obvious hallucinations sprinkled in.

Reverse Engineering the WAGO WebVisu Binary Protocol

My house runs on a WAGO 750-880 PLC for heating, ventilation, and light control. WAGO provides a WebVisu interface over HTTP — a browser-based control panel. What WAGO does not provide is documentation for the binary protocol it uses. I needed that protocol. Here's the weekend that followed.

The situation: 47 lights, no API

My home runs on a WAGO PLC — an industrial controller running CoDeSys 3 that manages 47 light switches distributed across three floors. WAGO provides WebVisu, a browser-based visualization platform built on top of their CoDeSys runtime: you design a touch panel in CoDeSys IDE, and it gets served as a browser interface over HTTPS at port 443. It works fine for tapping switches manually, but it has no programmatic API. Integrating the lights with the rest of my home automation stack required access the protocol underneath.

What makes this tractable is that WAGO serves the WebVisu JavaScript client from the PLC itself. Fetching https://192.168.1.10/webvisu/webvisu.js gives you the CoDeSys WebVisu client (CAS 1.0.0, v3.5.17.0) — minified, but complete. The client speaks the binary protocol; reading the client reveals the protocol. That was the entire approach.

Deobfuscating the JavaScript client

The client is about 350 meaningfully named identifiers compiled down to single- and double-character names. Eb is the frame header. lb and mb are the TLV reader and writer. n is the event message type. Reading this directly is possible but slow. The better path is an AST transform.

I wrote a jscodeshift transform (deobfuscate-transform.js) with the complete symbol mapping table. Building the table took several days — starting from the message builders and working outward, naming each class and function as its purpose became clear. Once the table was complete, the transform produces a readable version in under a second. The deobfuscated source is in the repository as reverse-engineering/webvisu-deobfuscated.js.

With readable source, the wire format dropped out directly from the binary writers and response parsers. I also documented the full catalog of all 107 paint commands by reading through the PaintCommandFactory.createCommand() method in the renderer — that's in reverse-engineering/PAINT-COMMANDS.md.

The frame format

Every message, in both directions, uses the same 20-byte header. Little-endian throughout:

Offset  Bytes  Type      Field
0       2      uint16    Magic (0xCD55 — identifies a valid frame)
2       2      uint16    Header length (always 16)
4       2      uint16    Service group (responses have 0x80 OR'd in)
6       2      uint16    Service ID
8       4      uint32    Session ID (0 until after OpenConnection)
12      4      uint32    Content length (payload bytes that follow)
16      4      uint32    Reserved (always 0)

The payload is TLV-encoded. Both tags and lengths use MBUI — a variable-length integer format where each byte contributes 7 data bits and bit 7 is a continuation flag. Values 0–127 encode in a single byte; larger values need additional bytes. It's the same scheme as MIDI variable-length quantities, little-endian. The protocol documentation is in reverse-engineering/PROTOCOL.md.

The connection handshake

Before the main polling loop, the client must work through a startup sequence. The full sequence is nine steps, each requiring its own service group and service ID combination:

  1. Fetch /webvisu/webvisu.cfg.json — configuration JSON with app name and protocol parameters
  2. OpenConnection (group 1 / service 1) — receives the sessionId that must be present in all subsequent frame headers
  3. GetMyIP (group 3) — retrieves the client IP as seen by the server, used in registration
  4. DeviceSession (group 1 / service 2) — device-level session creation
  5. RegisterClient (group 4 / service 1) — registers the visualization client, receives externId
  6. IsRegisteredClient (group 4 / service 3) — polls until status returns 0 (confirmed registered)
  7. GetPaintData with Viewport event (tag 516) — sets the display dimensions (1280×1024)
  8. GetPaintData with Capabilities event — exchanges protocol version and feature flags
  9. GetPaintData with StartVisu event — server responds with the initial paint command stream

After step 9, the client is in the main polling loop: send GetPaintData (group 4 / service 4) with any pending input events every 200ms, receive a stream of paint commands in response.

Paint commands and status detection

The server never sends variable values. It sends paint commands — 107 distinct drawing operations that describe what the UI should look like. The client is expected to maintain a virtual canvas and apply the commands. Paint commands are framed as: 4-byte total size + 4-byte command ID + variable-length command data, sequential until fewer than 8 bytes remain in the buffer.

For detecting light switch state, I don't render anything. The critical command is SetFillColor (command ID 4). When a light switch indicator is ON, the PLC sends a SetFillColor near the switch's screen coordinates with a yellow color (R > 140, G > 140). When OFF, it sends a brownish color. Reading the state of 47 switches means parsing the paint stream after navigating to each switch's position in the UI and collecting the fill color at the expected indicator coordinates.

Writing (toggling a switch) works by sending MouseDown and MouseUp events at the switch's screen coordinates. Coordinates are packed into a single 32-bit value: (x << 16) | (y & 0xFFFF). After sending the click events, the next GetPaintData poll returns the updated SetFillColor commands reflecting the new state.

The TypeScript implementation

The result is wago-webvisu-adapter — a Node.js/TypeScript service that wraps the protocol behind a REST API. The architecture has three layers: the protocol client (src/protocol/client.ts) handles the raw binary framing and session lifecycle; the protocol controller (src/protocol-controller.ts) implements higher-level operations like "navigate to this switch and read its state"; the Express API (src/api.ts) exposes the results over HTTP.

Three endpoints serve the integration:

  • GET /api/lights — all 47 lights with current on/off state, served from a SQLite cache
  • GET /api/lights/{id} — single light status (cached or live)
  • POST /api/lights/{id}/toggle — sends the click sequence to the PLC, returns updated state

The background polling service cycles through all 47 lights every 30 seconds, reading each state and writing to SQLite in WAL mode. API reads return from the cache immediately. There's no synchronous roundtrip to the PLC on each GET.

One significant complication: the PLC's panel uses a scrollable dropdown list for the light switches. To reach a specific switch, the controller must navigate to the correct tab, find the right scroll position, and click the correct row. This requires tracking the UI's state across operations — which tab is active, where the dropdown is scrolled — and resetting to a known-good state before each interaction. The src/model/ui-state.ts state machine handles this; src/commands/ensure-dropdown-closed.ts is the reset command that runs before each operation.

The implementation is validated by an acceptance test suite (src/test-acceptance.ts) with 11 integration tests against the live PLC hardware. These were essential for developing the scroll state logic — several of the edge cases I found only showed up when running against real hardware, not in protocol-level unit tests.

The MCP server

On top of the REST API sits a Python MCP server using SSE transport on port 3002, exposing three tools to Claude Desktop:

{
  "mcpServers": {
    "wago-webvisu": {
      "url": "http://localhost:3002/sse"
    }
  }
}

The three tools — list_lights, get_light_status, and toggle_light — are a thin wrapper over the REST API. The MCP server is about 100 lines of Python; all the real work happens in the Node.js adapter. This separation kept the MCP server simple and testable independently of the protocol logic.

Reverse engineering a protocol from its own JavaScript client is almost always faster than inferring it from binary captures alone. The deobfuscated source tells you which bytes mean what; raw captures tell you the values but not the semantics. Use both.

What was genuinely hard

The deobfuscation work was tedious but mechanical once the first 50 symbols were named — the naming cascades once you identify the core message builders. The harder problem was the dropdown scroll state. The PLC maintains the scroll position within a session, so each operation starts wherever the previous one left off. Getting scroll state wrong produces click events at the wrong rows, and recovering from a mid-operation failure requires a full session restart. The acceptance tests catch regressions, but the logic itself required more iteration than the protocol work.

The coordinate system was also non-obvious initially: X and Y packed into a single 32-bit parameter as (x << 16) | y. Nothing in the protocol documentation implied this; I found it by reading the event builder code after deobfuscation. Running the acceptance tests immediately confirmed whether the unpacking was correct — misaligned clicks produce no state change, which is unambiguous.