This ZView sample covers the most common setup in the field — a remote IO node where an upstream master controls ZPi four output channels bit by bit and reads the state of four input channels back the same way — on a single monitoring screen. The operator has to be able to see at a glance what the master is commanding right now and how far the inputs have come in.

The appeal of ZView is that a screen at this level fits into one HTML file, under 200 lines, and deployment means uploading that file once. No dedicated HMI IDE, no widget library, no build, no signing step.

ZView monitor in the active state

What the sample shows — a Modbus remote IO monitor

The Modbus remote IO monitor carries all of the following on one screen at the same time.

  • Four OUT channels — the outputs the upstream master has turned on, shown as large lights.
  • Four IN channels — field input states, in a different colour (orange) so they never get mixed up with OUT.
  • D[0] / D[10] cards — the raw value of the 16-bit words the master writes and reads, shown three ways: decimal, hexadecimal and a bit matrix. Bit-level debugging happens on the same screen.
  • Link status pill — active, idle and disconnected, distinguished by colour.
  • Server clock — the time on the ZPi itself, for debugging clock mismatches on the network.

This ZView is designed for monitoring only. Toggling an OUT from the screen would be pointless because the master value overwrites it within 50ms — the master is the source of truth and ZView is its mirror. That separation keeps operational responsibility clear.

Prerequisites — Modbus and the mirror script

The Modbus RTU slave configuration and the mirror script are the prerequisites. Before deciding what ZView should display, the data flow in the backend has to be nailed down. The backend for this sample has two parts.

(1) The RTU slave configuration on ZPi — one serial port on the Raspberry Pi is dedicated entirely to a Modbus RTU slave.

rtuSlave:
  enabled: true
  port:     /dev/serial0
  baud:     115200
  parity:   None
  stopBits: 1
  unitId:   1

Save it and run sudo systemctl restart zpi-controller once. Match the same parameters on the master side, check the RS-485 A/B/GND wiring, and the link comes up.

(2) An IronPython mirror script that runs every 50 ms tick — it unpacks the low four bits of D[0] into OUT[0..3] and packs IN[0..3] into the low four bits of D[10].

def Tick():
    d0 = D[0]
    Out[0] = bool(d0 & 0x0001)
    Out[1] = bool(d0 & 0x0002)
    Out[2] = bool(d0 & 0x0004)
    Out[3] = bool(d0 & 0x0008)

    d10 = 0
    if In[0]: d10 |= 0x0001
    if In[1]: d10 |= 0x0002
    if In[2]: d10 |= 0x0004
    if In[3]: d10 |= 0x0008
    D[10] = d10

With those two in place, ZView only has to add the visualisation on top. The memory map is as simple as this.

Memory map for D[0] and D[10] — the low four bits of Holding 28672 and 28682

One more debugging note. On the first run the master was sending D[0]=3840 (0x0F00). The script only looks at the low four bits, so the outputs never followed. The cause was a byte swap on the master side, and one configuration change there brought it in correctly as 0x000F. The ZView bit matrix pinpoints exactly this kind of situation — “the value is arriving but it is sitting in the wrong bit positions” is visible directly on the screen.

Byte swap debugging capture — the same 0F has to move from the high byte (BEFORE) to the low byte (AFTER) for the outputs to work

Five ZView design decisions

A single HTML file is what sets ZView apart from other HMIs. That constraint cuts down the number of design decisions you have to make.

1. A fixed dark theme. It is the safest choice under factory fluorescent lighting and during night operation. The colour variables live in inline CSS in the body.

body  { background: #0a0e1a; color: #e6ecf5; }
.light          { background: #161c2c; border: 2px solid #232b40; }
.light.on       { background: linear-gradient(180deg, #00c879 0%, #009a5e 100%);
                  box-shadow: 0 0 24px rgba(46, 230, 154, 0.45); }
.light.in.on    { background: linear-gradient(180deg, #ff9e3a 0%, #d4781f 100%); }

OUT glows green, IN glows orange. Separating outputs from inputs by colour is the single biggest reduction in operator cognitive load.

2. Touch-friendly large lights. At least 80 px tall with aspect-ratio: 1.4 / 1. Checking state with a fingertip has to feel natural. user-scalable=no and -webkit-tap-highlight-color: transparent prevent accidental zoom and highlight flicker on touch.

3. Bit matrix visualisation. The 16 bits of D[0] and D[10] are laid out as 16 small cells in a row. A byte swap, a bit-position typo or a word-endian problem becomes visible immediately — this is the part that genuinely saves time on site.

function setBits(cells, val) {
  for (let i = 0; i < 16; i++) {
    cells[i].classList.toggle('on', !!(val & (1 << i)));
  }
}

4. A three-state link pill. Active, idle and disconnected, separated by colour.

  • Active (green) — D[0] changed at least once in the last 3 seconds, so the master really is writing.
  • Idle (yellow) — the link is alive but D[0] has not changed for N seconds, so the master is idle.
  • Disconnected (red) — /api/io is not responding, so it is the ZPi itself or the network.

ZView monitor in the disconnected state

These three states are what separate “the link is fine but the master is not sending” from “the link itself is dead”. Showing the same grey OUT lights in both cases leaves the operator unable to tell them apart.

5. Works without internet. Zero dependency on external CDNs, fonts or icons. One static file served from ZPi is the whole thing. Losing internet on a factory floor is routine, not an incident.

The core code — one polling function

One polling function carries the entire behaviour of this ZView. It GETs /api/io every 200 ms and reflects the response into three areas of the screen: the lights, the card values and the bit matrix.

async function poll() {
  try {
    const r = await fetch('/api/io', { cache: 'no-store' });
    if (!r.ok) throw new Error('HTTP ' + r.status);
    const s = await r.json();

    // 1) four-channel lights
    for (let i = 0; i < 4; i++) {
      outLights[i].classList.toggle('on', !!s.outputs[i]);
      inLights[i].classList.toggle('on',  !!s.inputs[i]);
    }

    // 2) raw D[0] / D[10] values
    const d0  = readDword(s, 0);
    const d10 = readDword(s, 10);
    $('d0dec').textContent  = d0;
    $('d0hex').textContent  = hex4(d0);
    $('d10dec').textContent = d10;
    $('d10hex').textContent = hex4(d10);

    // 3) bit matrix, 32 cells (16 bits each)
    setBits(d0BitCells,  d0);
    setBits(d10BitCells, d10);

    // 4) link status pill (decided by when D[0] last changed)
    if (d0 !== lastD0) { lastD0 = d0; lastChangeMs = Date.now(); }
    updateCommStatus();

  } catch (e) {
    setCommStatus('연결 끊김', 'err');
  }
}
setInterval(poll, 200);

readDword() is a one-line adapter that accepts s.modbus whether it arrives as an object, an array or something else. ZView survives small changes to the ZPi API response format across versions.

Deployment — one zview_deploy

ZView deployment is a single ZPi MCP tool call with the finished HTML. ZView is exposed at /zview/ automatically, and any operator browser already open refreshes along with it thanks to SSE auto-reload.

zview_deploy(
  name:     "modbus-io",
  html:     "...",         # a single self-contained HTML file
  activate: true
)

The metadata after deployment:

{
  "ok": true,
  "meta": {
    "name": "modbus-io",
    "sizeBytes": 10018,
    "isActive": true
  }
}

10 KB. That is the entire weight of an IO monitor page. For comparison, an equivalent panel in Wonderware or Crimson runs to several MB.

Putting it on a touch display with a kiosk browser

Displaying it on the touch panel uses the Senvas Touch display attached to this setup. The touch side runs its own primary program full screen, so displaying ZView means launching one additional browser in kiosk mode pointed at http://<zpi-ip>:5000/zview/. The detailed setup for that will be a separate post; this one is focused on the design and code of ZView itself.

Variations — growing from the same skeleton

ZView sample variations were easy to plan because the sample was written to branch for other purposes.

  • Pump monitor — put pumps 1 to 4 where the four OUT lights are, and pressure sensor trip states where the IN lights are. The D[0] bit distribution and D[10] packing pattern stay the same.
  • Conveyor line monitor — 8 to 12 channel states in place of the lights (extend the grid), with the D bit matrix left in place as the debugging panel.
  • Alarm board — add an alarm name and timestamp label to each light, and use a dedicated /api/data/{i} instead of D[0] to send acknowledge and reset commands.

At heart ZView is nothing more than “poll JSON, toggle DOM,” and that pattern covers most industrial monitoring screens. One HTML file carrying as much expressive power as a screen built in an IDE is the core value of ZView, and this sample is the shortest possible demonstration of it.

Reflections — what a single-file ZView leaves you with

  • The ZView build-and-deploy cycle is one breath: write one HTML file, call zview_deploy once, and the operator browser refreshes itself. Because the cycle is short, design decisions can be tried out quickly.
  • The single-file constraint simplifies design. With no external libraries, design system or build tooling available, only the essentials survive. An industrial monitor is fundamentally “value to pixel” anyway, so the constraint fits.
  • One small visual element like a bit matrix can decisively cut debugging time. Being able to spot a word-endian swap on screen, without alarms, logs or a tracer, is the part of this sample we are most pleased with.

Polling JSON and toggling the DOM covers most industrial monitoring screens, and one bit matrix decisively cuts debugging time.

Contact