Latest commit

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Device Reporter (formerly ScaleReporter)

A small Rust service that reads clinic devices over USB serial and publishes their results over HTTP and WebSocket, so a weight (and soon a blood pressure, a height, a urinalysis, a hemoglobin) can flow into the chart without anyone retyping it.

It started life as a Python script for one scale. It is now a generic reporter with a driver per device. Drivers so far: the Health o meter large-platform scale (1100 / 2000 series, "L" and "E" serial versions) over its CP210x USB-to-UART option and the McKesson Consult 120 urine analyzer over its USB serial port. Adding a device means writing one driver module; everything else (detection, hot-plug, the API, the page) is shared.


Current setup at my office:

current-setup-scale.jpg

current-setup-scale-2.jpg

Navigating to the Web UI

Scale Web UI

device-reporter-scale.png

Urinalysis UI

device-reporter-urinalysis.png

What it does

  • Auto-detects devices. Serial ports are scanned every few seconds and matched to drivers by USB vendor/product ID. Unplug and replug at will.
  • One event per result, not one per packet. The scale streams the same locked weight every second while someone stands on it. The driver coalesces that into a single observation when they step off. A different weight (a child hopping on after a parent) starts a new one.
  • Plausibility flags for the clinician.below_minimum for a bag or a foot on the platform, single_packet when the scale only re-displayed a previous weight (RECALL/UNITS button).
  • FHIR-ready output. Every result carries LOINC codes and UCUM units, so the EMR can build Observation resources without device-specific knowledge.
  • Live page, REST API, and a WebSocket stream with reconnection and keep-alive pings.
  • list and sniff subcommands for reverse-engineering the next device's protocol.
  • Single static binary, cross-compiled for the Raspberry Pi Zero W. No Python, no venv, no driver install on Linux.

HIPAA / Security

This is a work in progress and does not by itself protect health information. It serves plain HTTP on the address you bind it to, with no authentication. Results contain a weight, timestamps, and whatever ID was typed on the device keypad.

Mitigations used here: bind to loopback and put nginx in front; run the Pi on Tailscale and let UFW admit only the tailnet (see below); keep logs at INFO, which never print device-entered IDs. Extreme care must be used in order to avoid HIPAA violations. You have been warned; this project and its contributors accept no responsibility for how it is deployed.


Transferring it to the Raspberry Pi:

cd PycharmProject/ScaleReporter/target/pi/arm-unknown-linux-gnueabihf/release
scp device-reporter shawn@bmpc-kent-scale:device-reporter

Caveat when updating: Linux will not overwrite a binary that is running (scp: dest open "device-reporter": Failure, "text file busy"). Stop the service first, copy, then start it again:

ssh shawn@bmpc-kent-scale sudo systemctl stop device-reporter
scp device-reporter shawn@bmpc-kent-scale:device-reporter
ssh shawn@bmpc-kent-scale sudo systemctl start device-reporter

Running it

cargo run --release # serve on 127.0.0.1:8080, auto-detect devices
cargo run --release -- --demo # no hardware: a simulated scale weighs a visitor every 15 s
cargo run --release -- list # serial ports with VID:PID, serial number, product
cargo run --release -- sniff COM5 # hex + text dump of whatever COM5 sends

Open http://localhost:8080/ and step on the scale.

Every flag has an environment variable (DR_*); run device-reporter --help. The ones you will actually use:

FlagEnvDefaultPurpose
--bindDR_BIND127.0.0.1:8080Listen address. Use 0.0.0.0:8080 to reach it from another machine without nginx.
--assign PORT=DRIVERDR_ASSIGNForce a driver onto a port, e.g. /dev/scale=healthometer_scale.
--fallback-driverDR_FALLBACK_DRIVERDriver for /dev/ttyUSB* or /dev/ttyACM* ports that expose no USB descriptors. Normally unnecessary; the CP210x is recognised by VID:PID.
--cors-originDR_CORS_ORIGINBrowser origins allowed to call the API cross-origin; needed for the WASM build of the EMR client.
--hostDR_HOSThostnameName reported as host and used in device IDs.
--scale-quiet-msDR_SCALE_QUIET_MS2500Silence that ends a weigh-in.
--scale-min-weight-kgDR_SCALE_MIN_WEIGHT_KG1Below this the result is flagged below_minimum.

On Windows the scale needs the Silicon Labs CP210x driver. Linux has it built in.

API

RouteReturns
GET /The status page.
GET /api/statusProcess info plus every device seen since start.
GET /api/devicesJust the device list.
GET /api/latest[?device=ID]Most recent observation, optionally from one device. 404 until there is one.
GET /api/observations[?device=ID&limit=N]Recent observations, newest first.
GET /wsLive event stream (below).

Events

Every WebSocket message is JSON with v (wire version, currently 1) and type. On connect you get a server snapshot and the latest observation from each device, then live events.

// server: sent once on connect
{"v":1,"type":"server","host":"scale-pi","version":"0.2.0","started_at":"","devices":[]}
// device: a device connected, disconnected, or started/stopped measuring
{"v":1,"type":"device","id":"scale-pi-dev-ttyUSB0","kind":"healthometer_scale",
"display_name":"Health o meter scale","port":"/dev/ttyUSB0","connected":true,
"last_error":null,"last_data_at":"","active":true}
// reading: provisional, once per second while someone is on the scale
{"v":1,"type":"reading","device_id":"","device_kind":"healthometer_scale","at":"",
"subject_hint":null,"components":[{"code":"29463-7","display":"Body weight","value":184.5,"unit":"[lb_av]"}]}
// observation: one completed result
{"v":1,"type":"observation","id":"d094ff0f-…","device_id":"","device_kind":"healthometer_scale",
"captured_at":"","completed_at":"","subject_hint":null,
"components":[
{"code":"29463-7","display":"Body weight","value":72.4,"unit":"kg"},
{"code":"8302-2","display":"Body height","value":178.0,"unit":"cm"},
{"code":"39156-5","display":"Body mass index","value":22.9,"unit":"kg/m2"}],
"flags":[],"packets":5}

components[].code is LOINC; unit is UCUM (kg, [lb_av], cm, [in_i], kg/m2, later mm[Hg], g/dL, …). value is a number or, for coded results such as a urinalysis "trace", a string. subject_hint is whatever ID the device itself carried (the scale keypad); it is a hint for the clinician, never an identity. id is random per observation so the EMR can accept or discard exactly one.

How the scale driver works

The protocol (see reference/HealthometerProf.CommunicationProtocols*.pdf) is 9600 8N1 and each packet looks like this, with no newline:

<ESC>R<ESC>I1234567890<ESC>W184.5<ESC>H84.0<ESC>B24.1<ESC>T0.0<ESC>Nm<ESC>E

src/drivers/healthometer/protocol.rs frames on the <ESC>E terminator, anchors on the last R field so a fragment from connecting mid-stream can never be glued onto the next packet and misread as its weight, and refuses packets with no weight or no units. session.rs turns the once-per-second stream into one result. Both are pure and unit-tested against the packets printed in the PDF.

Adding a device

  1. Find it: device-reporter list shows VID:PID and product strings.
  2. Learn its protocol: device-reporter sniff COM7 --baud 9600 (and --parity, --data-bits, --send-hex "1b 52" for devices that need a request). --capture file.bin saves the raw bytes for a unit test.
  3. Create src/drivers/<device>/mod.rs implementing Driver (how to recognise the port and its line settings) and DeviceSession (bytes in, Output::Live / Output::Complete out). Look at the healthometer driver.
  4. Register it in driver::registry.

Devices queued up: Welch Allyn Spot Vital Signs LXi, Detecto sonar stadiometer, hemoglobin meter. Findings so far, including which port each device actually talks on, are in docs/devices.md.

Project layout

├── Cargo.toml
├── src/
│ ├── main.rs CLI (serve / list / sniff), wiring
│ ├── model.rs Observation, Component, DeviceStatus, Event: the JSON contract
│ ├── driver.rs Driver and DeviceSession traits, port matching, registry
│ ├── drivers/
│ │ ├── healthometer/ protocol.rs (framing, parsing), session.rs (coalescing), mod.rs (driver)
│ │ └── consult120.rs urine analyzer: STX/ETX text report to LOINC components
│ ├── manager.rs port scanning, driver pairing, hot-plug, one thread per device
│ ├── serial.rs the blocking connection loop shared by every serial driver
│ ├── state.rs shared state and the broadcast channel
│ ├── web.rs axum routes and the WebSocket
│ ├── sniff.rs `list` and `sniff`
│ └── demo.rs a fake scale for `--demo`
├── static/index.html the status page, embedded in the binary
├── docs/devices.md per-device notes: what is known, what was tried, what is next
└── reference/ the manufacturer protocol PDF, wiring photos, screenshots

cargo test runs 45 unit tests, none of which need hardware. cargo clippy --all-targets is clean under the strict lint set in Cargo.toml (no unwrap, expect, panic or indexing in non-test code).


Raspberry Pi deployment

Background: I am a Family Physician and love tinkering, programming, teaching and learning. I like when my hobbies can intersect with my profession as well. This is the permanently installed Raspberry Pi that lives behind the scale, documented so I can rebuild it if it is ever lost, and so anyone else can try it.

Audience: someone who knows what a terminal is but may not be overly familiar with Linux.

Consider trying the project from your laptop first (cargo run --release -- --bind 0.0.0.0:8080) to see that your scale is compatible before buying anything.

Parts list

  • Raspberry Pi Zero W (32-bit, ARMv6), which I use, or a Raspberry Pi Zero 2 W (~$15). Any Pi works; this is a stupidly low load, and the Zero draws only about 0.6-1.2 W.
  • Case for the Pi. I 3D printed one.
  • Micro-USB cable for power, or a USB power-only cable with switch.
  • MicroSD card, at least 1 GB.
  • Micro-USB to USB-B cable. Hard to find; I bought these on Amazon. A USB Mini-to-A adapter also works, or use a full-size Pi.
  • A scale with the connectivity option. Hopefully you have this already if you are reading this.

Scale label

Image the Pi (headless)

Generate an SSH key pair

Set up an SSH key before imaging so password logins can be disabled. On Windows I use PuTTY; PuTTYgen, included in the download, generates the key. Save it somewhere safe; you will paste the public key into the imager next.

PuTTYgen

Write Raspberry Pi OS

Install Raspberry Pi OS (Lite is fine) with Raspberry Pi Imager. In the settings, choose a hostname (I use scale-pi), enter the Wi-Fi password, enable SSH, and paste the public key.

Raspberry Pi Imager

Save the PuTTY session

  • Host name: scale-pi.local
  • Connection → SSH → Auth → Credentials: your .ppk file
  • Connection → Data: your username
  • Session: give it a name and Save

First boot and login

Pop the card in, power up, wait a minute, open the saved PuTTY session, accept the host key.

PuTTY alert

Hint: right-clicking the terminal pastes.

Update everything, then reboot:

sudo apt update && sudo apt upgrade -y
sudo raspi-config # expand filesystem, set logging to volatile
sudo shutdown -r now

Build the binary for the Pi (Or skip this section and download the release)

Cross-compile on your PC; the Pi Zero would take an age to compile Rust. From WSL (Ubuntu), with Rust, zig and cargo install cargo-zigbuild, once:

cd /mnt/c/Users/<you>/PycharmProject/ScaleReporter
rustup target add arm-unknown-linux-gnueabihf # 32-bit: Pi Zero, Zero W, Pi 1 (ARMv6)
rustup target add aarch64-unknown-linux-gnu # 64-bit: Zero 2 W, Pi 3/4/5 on 64-bit Raspberry Pi OS

Then, per release:

# Pi Zero / Zero W (32-bit)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target arm-unknown-linux-gnueabihf.2.31
# -> target/pi/arm-unknown-linux-gnueabihf/release/device-reporter# Pi Zero 2 W, Pi 3/4/5 (64-bit OS)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.31
# -> target/pi/aarch64-unknown-linux-gnu/release/device-reporter

Pick by the OS you imaged, not only the board: a Zero 2 W running the 32-bit Raspberry Pi OS needs the 32-bit binary. uname -m on the Pi says armv6l/armv7l for 32-bit and aarch64 for 64-bit. The .2.31 pins glibc 2.31 (Raspberry Pi OS Bullseye), which also runs on Bookworm (2.36) and newer. Each binary is about 2 MB with no dependencies beyond glibc. CARGO_TARGET_DIR=target/pi keeps the WSL build out of the Windows target/ directory.

Copy it over with scp as shown in Transferring it to the Raspberry Pi above.

Plug in and test

Plug the scale into the Pi (the Zero's inner micro-USB port is data; the outer one is power only) and run the binary:

~/device-reporter --bind 0.0.0.0:8080

Within a few seconds the log shows opening device ... port=/dev/ttyUSB0 driver=healthometer_scale. The CP210x bridge is recognized by its USB vendor/product ID, so no port name or driver flag is needed, and the default Raspberry Pi OS user is already in the dialout group. If a port ever shows up as "no matching driver", ~/device-reporter list shows what the OS knows about it and --assign /dev/ttyUSB0=healthometer_scale forces the pairing.

Browse to http://.local:8080/, step on the scale, Ctrl-C when happy.

Run it as a service

sudo nano /etc/systemd/system/device-reporter.service
[Unit]
Description=Device Reporter (clinic USB devices)
After=network.target
[Service]
ExecStart=/home/shawn/device-reporter
WorkingDirectory=/home/shawn
Environment=DR_BIND=127.0.0.1:8080
Environment=RUST_LOG=info
Restart=always
RestartSec=3
User=shawn
Group=dialout
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now device-reporter.service
journalctl -u device-reporter.service -f # follow the logs

To upgrade: sudo systemctl stop device-reporter, scp the new binary over, then sudo systemctl start device-reporter. Copying while the service runs fails with "text file busy" because Linux will not overwrite an executable that is in use.

DR_BIND=127.0.0.1 means only nginx on the Pi can reach it, which is what you want once the next section is done. For testing without nginx, use 0.0.0.0:8080.

Optional basic security improvements

Tailscale

I installed Tailscale because it just works. It encrypts everything with WireGuard and gives easy access control: any device on the tailnet can reach the Pi from anywhere; nothing else can. The Tailscale admin page generates the install command:

curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up --auth-key=tskey-auth-.....
sudo tailscale set --auto-update

nginx

Forward :80 to the service so no port is needed in the URL, and so TLS can be added later.

sudo apt install nginx -y
sudo rm /etc/nginx/sites-enabled/default
sudo nano /etc/nginx/sites-available/device-reporter
server{listen80;server_name _;location / {proxy_passhttp://127.0.0.1:8080;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme; # WebSocketproxy_http_version 1.1;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade"; # The service pings every 20 s; this is just belt and braces for quiet nights.proxy_read_timeout1h;}}
sudo ln -s /etc/nginx/sites-available/device-reporter /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Now http://scale-pi/ works from any tailnet device.

UFW firewall

Admit SSH and HTTP only over Tailscale:

sudo apt install ufw -y
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0 to any port 22
sudo ufw allow in on tailscale0 to any port 80
sudo ufw allow in on tailscale0 to any port 443
sudo ufw allow 41641/udp # helps Tailscale connect peer-to-peer
sudo ufw status numbered
sudo ufw enable
sudo service ssh restart

Ultimate test: the page and SSH work with Tailscale running on your PC and fail with it quit. That is a zero-trust setup: only authorized devices can reach the Pi at all.


Next: into the chart

The plan is for the EMR client (front_desk, an egui app that already speaks WebSocket) to subscribe to /ws, and when an observation arrives while a chart is open, show it as a pending vital: "Weight 184.5 lb from the Room 2 scale, 12 s ago. Accept into this chart?" The clinician confirms it is the right person (not their kid playing on the scale) and the client writes a FHIR Observation with device provenance. Nothing is charted automatically.

Longer term the Pi should post observations to the FHIR server instead, which gives a device registry, room-to-device mapping, an audit trail, and a pending queue that survives client restarts.

Contributing

Contributions are welcome. Add a driver for your device; a raw capture from sniff --capture makes a great unit test fixture.

License

MIT. You are free to use, modify, and distribute this software, provided that proper attribution is given.

Acknowledgments

  • Health o meter (Pelstar) for hardware that has worked for 10+ years and for publishing their serial protocol.
  • The serialport, axum, tokio and jiff crates.

About

This project interfaces with an electronic scale via USB and provides real-time weight updates through a web interface and an API.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Latest commit

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Device Reporter (formerly ScaleReporter)

A small Rust service that reads clinic devices over USB serial and publishes their results over HTTP and WebSocket, so a weight (and soon a blood pressure, a height, a urinalysis, a hemoglobin) can flow into the chart without anyone retyping it.

It started life as a Python script for one scale. It is now a generic reporter with a driver per device. Drivers so far: the Health o meter large-platform scale (1100 / 2000 series, "L" and "E" serial versions) over its CP210x USB-to-UART option and the McKesson Consult 120 urine analyzer over its USB serial port. Adding a device means writing one driver module; everything else (detection, hot-plug, the API, the page) is shared.


Current setup at my office:

current-setup-scale.jpg

current-setup-scale-2.jpg

Navigating to the Web UI

Scale Web UI

device-reporter-scale.png

Urinalysis UI

device-reporter-urinalysis.png

What it does

  • Auto-detects devices. Serial ports are scanned every few seconds and matched to drivers by USB vendor/product ID. Unplug and replug at will.
  • One event per result, not one per packet. The scale streams the same locked weight every second while someone stands on it. The driver coalesces that into a single observation when they step off. A different weight (a child hopping on after a parent) starts a new one.
  • Plausibility flags for the clinician.below_minimum for a bag or a foot on the platform, single_packet when the scale only re-displayed a previous weight (RECALL/UNITS button).
  • FHIR-ready output. Every result carries LOINC codes and UCUM units, so the EMR can build Observation resources without device-specific knowledge.
  • Live page, REST API, and a WebSocket stream with reconnection and keep-alive pings.
  • list and sniff subcommands for reverse-engineering the next device's protocol.
  • Single static binary, cross-compiled for the Raspberry Pi Zero W. No Python, no venv, no driver install on Linux.

HIPAA / Security

This is a work in progress and does not by itself protect health information. It serves plain HTTP on the address you bind it to, with no authentication. Results contain a weight, timestamps, and whatever ID was typed on the device keypad.

Mitigations used here: bind to loopback and put nginx in front; run the Pi on Tailscale and let UFW admit only the tailnet (see below); keep logs at INFO, which never print device-entered IDs. Extreme care must be used in order to avoid HIPAA violations. You have been warned; this project and its contributors accept no responsibility for how it is deployed.


Transferring it to the Raspberry Pi:

cd PycharmProject/ScaleReporter/target/pi/arm-unknown-linux-gnueabihf/release
scp device-reporter shawn@bmpc-kent-scale:device-reporter

Caveat when updating: Linux will not overwrite a binary that is running (scp: dest open "device-reporter": Failure, "text file busy"). Stop the service first, copy, then start it again:

ssh shawn@bmpc-kent-scale sudo systemctl stop device-reporter
scp device-reporter shawn@bmpc-kent-scale:device-reporter
ssh shawn@bmpc-kent-scale sudo systemctl start device-reporter

Running it

cargo run --release # serve on 127.0.0.1:8080, auto-detect devices
cargo run --release -- --demo # no hardware: a simulated scale weighs a visitor every 15 s
cargo run --release -- list # serial ports with VID:PID, serial number, product
cargo run --release -- sniff COM5 # hex + text dump of whatever COM5 sends

Open http://localhost:8080/ and step on the scale.

Every flag has an environment variable (DR_*); run device-reporter --help. The ones you will actually use:

FlagEnvDefaultPurpose
--bindDR_BIND127.0.0.1:8080Listen address. Use 0.0.0.0:8080 to reach it from another machine without nginx.
--assign PORT=DRIVERDR_ASSIGNForce a driver onto a port, e.g. /dev/scale=healthometer_scale.
--fallback-driverDR_FALLBACK_DRIVERDriver for /dev/ttyUSB* or /dev/ttyACM* ports that expose no USB descriptors. Normally unnecessary; the CP210x is recognised by VID:PID.
--cors-originDR_CORS_ORIGINBrowser origins allowed to call the API cross-origin; needed for the WASM build of the EMR client.
--hostDR_HOSThostnameName reported as host and used in device IDs.
--scale-quiet-msDR_SCALE_QUIET_MS2500Silence that ends a weigh-in.
--scale-min-weight-kgDR_SCALE_MIN_WEIGHT_KG1Below this the result is flagged below_minimum.

On Windows the scale needs the Silicon Labs CP210x driver. Linux has it built in.

API

RouteReturns
GET /The status page.
GET /api/statusProcess info plus every device seen since start.
GET /api/devicesJust the device list.
GET /api/latest[?device=ID]Most recent observation, optionally from one device. 404 until there is one.
GET /api/observations[?device=ID&limit=N]Recent observations, newest first.
GET /wsLive event stream (below).

Events

Every WebSocket message is JSON with v (wire version, currently 1) and type. On connect you get a server snapshot and the latest observation from each device, then live events.

// server: sent once on connect
{"v":1,"type":"server","host":"scale-pi","version":"0.2.0","started_at":"","devices":[]}
// device: a device connected, disconnected, or started/stopped measuring
{"v":1,"type":"device","id":"scale-pi-dev-ttyUSB0","kind":"healthometer_scale",
"display_name":"Health o meter scale","port":"/dev/ttyUSB0","connected":true,
"last_error":null,"last_data_at":"","active":true}
// reading: provisional, once per second while someone is on the scale
{"v":1,"type":"reading","device_id":"","device_kind":"healthometer_scale","at":"",
"subject_hint":null,"components":[{"code":"29463-7","display":"Body weight","value":184.5,"unit":"[lb_av]"}]}
// observation: one completed result
{"v":1,"type":"observation","id":"d094ff0f-…","device_id":"","device_kind":"healthometer_scale",
"captured_at":"","completed_at":"","subject_hint":null,
"components":[
{"code":"29463-7","display":"Body weight","value":72.4,"unit":"kg"},
{"code":"8302-2","display":"Body height","value":178.0,"unit":"cm"},
{"code":"39156-5","display":"Body mass index","value":22.9,"unit":"kg/m2"}],
"flags":[],"packets":5}

components[].code is LOINC; unit is UCUM (kg, [lb_av], cm, [in_i], kg/m2, later mm[Hg], g/dL, …). value is a number or, for coded results such as a urinalysis "trace", a string. subject_hint is whatever ID the device itself carried (the scale keypad); it is a hint for the clinician, never an identity. id is random per observation so the EMR can accept or discard exactly one.

How the scale driver works

The protocol (see reference/HealthometerProf.CommunicationProtocols*.pdf) is 9600 8N1 and each packet looks like this, with no newline:

<ESC>R<ESC>I1234567890<ESC>W184.5<ESC>H84.0<ESC>B24.1<ESC>T0.0<ESC>Nm<ESC>E

src/drivers/healthometer/protocol.rs frames on the <ESC>E terminator, anchors on the last R field so a fragment from connecting mid-stream can never be glued onto the next packet and misread as its weight, and refuses packets with no weight or no units. session.rs turns the once-per-second stream into one result. Both are pure and unit-tested against the packets printed in the PDF.

Adding a device

  1. Find it: device-reporter list shows VID:PID and product strings.
  2. Learn its protocol: device-reporter sniff COM7 --baud 9600 (and --parity, --data-bits, --send-hex "1b 52" for devices that need a request). --capture file.bin saves the raw bytes for a unit test.
  3. Create src/drivers/<device>/mod.rs implementing Driver (how to recognise the port and its line settings) and DeviceSession (bytes in, Output::Live / Output::Complete out). Look at the healthometer driver.
  4. Register it in driver::registry.

Devices queued up: Welch Allyn Spot Vital Signs LXi, Detecto sonar stadiometer, hemoglobin meter. Findings so far, including which port each device actually talks on, are in docs/devices.md.

Project layout

├── Cargo.toml
├── src/
│ ├── main.rs CLI (serve / list / sniff), wiring
│ ├── model.rs Observation, Component, DeviceStatus, Event: the JSON contract
│ ├── driver.rs Driver and DeviceSession traits, port matching, registry
│ ├── drivers/
│ │ ├── healthometer/ protocol.rs (framing, parsing), session.rs (coalescing), mod.rs (driver)
│ │ └── consult120.rs urine analyzer: STX/ETX text report to LOINC components
│ ├── manager.rs port scanning, driver pairing, hot-plug, one thread per device
│ ├── serial.rs the blocking connection loop shared by every serial driver
│ ├── state.rs shared state and the broadcast channel
│ ├── web.rs axum routes and the WebSocket
│ ├── sniff.rs `list` and `sniff`
│ └── demo.rs a fake scale for `--demo`
├── static/index.html the status page, embedded in the binary
├── docs/devices.md per-device notes: what is known, what was tried, what is next
└── reference/ the manufacturer protocol PDF, wiring photos, screenshots

cargo test runs 45 unit tests, none of which need hardware. cargo clippy --all-targets is clean under the strict lint set in Cargo.toml (no unwrap, expect, panic or indexing in non-test code).


Raspberry Pi deployment

Background: I am a Family Physician and love tinkering, programming, teaching and learning. I like when my hobbies can intersect with my profession as well. This is the permanently installed Raspberry Pi that lives behind the scale, documented so I can rebuild it if it is ever lost, and so anyone else can try it.

Audience: someone who knows what a terminal is but may not be overly familiar with Linux.

Consider trying the project from your laptop first (cargo run --release -- --bind 0.0.0.0:8080) to see that your scale is compatible before buying anything.

Parts list

  • Raspberry Pi Zero W (32-bit, ARMv6), which I use, or a Raspberry Pi Zero 2 W (~$15). Any Pi works; this is a stupidly low load, and the Zero draws only about 0.6-1.2 W.
  • Case for the Pi. I 3D printed one.
  • Micro-USB cable for power, or a USB power-only cable with switch.
  • MicroSD card, at least 1 GB.
  • Micro-USB to USB-B cable. Hard to find; I bought these on Amazon. A USB Mini-to-A adapter also works, or use a full-size Pi.
  • A scale with the connectivity option. Hopefully you have this already if you are reading this.

Scale label

Image the Pi (headless)

Generate an SSH key pair

Set up an SSH key before imaging so password logins can be disabled. On Windows I use PuTTY; PuTTYgen, included in the download, generates the key. Save it somewhere safe; you will paste the public key into the imager next.

PuTTYgen

Write Raspberry Pi OS

Install Raspberry Pi OS (Lite is fine) with Raspberry Pi Imager. In the settings, choose a hostname (I use scale-pi), enter the Wi-Fi password, enable SSH, and paste the public key.

Raspberry Pi Imager

Save the PuTTY session

  • Host name: scale-pi.local
  • Connection → SSH → Auth → Credentials: your .ppk file
  • Connection → Data: your username
  • Session: give it a name and Save

First boot and login

Pop the card in, power up, wait a minute, open the saved PuTTY session, accept the host key.

PuTTY alert

Hint: right-clicking the terminal pastes.

Update everything, then reboot:

sudo apt update && sudo apt upgrade -y
sudo raspi-config # expand filesystem, set logging to volatile
sudo shutdown -r now

Build the binary for the Pi (Or skip this section and download the release)

Cross-compile on your PC; the Pi Zero would take an age to compile Rust. From WSL (Ubuntu), with Rust, zig and cargo install cargo-zigbuild, once:

cd /mnt/c/Users/<you>/PycharmProject/ScaleReporter
rustup target add arm-unknown-linux-gnueabihf # 32-bit: Pi Zero, Zero W, Pi 1 (ARMv6)
rustup target add aarch64-unknown-linux-gnu # 64-bit: Zero 2 W, Pi 3/4/5 on 64-bit Raspberry Pi OS

Then, per release:

# Pi Zero / Zero W (32-bit)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target arm-unknown-linux-gnueabihf.2.31
# -> target/pi/arm-unknown-linux-gnueabihf/release/device-reporter# Pi Zero 2 W, Pi 3/4/5 (64-bit OS)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.31
# -> target/pi/aarch64-unknown-linux-gnu/release/device-reporter

Pick by the OS you imaged, not only the board: a Zero 2 W running the 32-bit Raspberry Pi OS needs the 32-bit binary. uname -m on the Pi says armv6l/armv7l for 32-bit and aarch64 for 64-bit. The .2.31 pins glibc 2.31 (Raspberry Pi OS Bullseye), which also runs on Bookworm (2.36) and newer. Each binary is about 2 MB with no dependencies beyond glibc. CARGO_TARGET_DIR=target/pi keeps the WSL build out of the Windows target/ directory.

Copy it over with scp as shown in Transferring it to the Raspberry Pi above.

Plug in and test

Plug the scale into the Pi (the Zero's inner micro-USB port is data; the outer one is power only) and run the binary:

~/device-reporter --bind 0.0.0.0:8080

Within a few seconds the log shows opening device ... port=/dev/ttyUSB0 driver=healthometer_scale. The CP210x bridge is recognized by its USB vendor/product ID, so no port name or driver flag is needed, and the default Raspberry Pi OS user is already in the dialout group. If a port ever shows up as "no matching driver", ~/device-reporter list shows what the OS knows about it and --assign /dev/ttyUSB0=healthometer_scale forces the pairing.

Browse to http://.local:8080/, step on the scale, Ctrl-C when happy.

Run it as a service

sudo nano /etc/systemd/system/device-reporter.service
[Unit]
Description=Device Reporter (clinic USB devices)
After=network.target
[Service]
ExecStart=/home/shawn/device-reporter
WorkingDirectory=/home/shawn
Environment=DR_BIND=127.0.0.1:8080
Environment=RUST_LOG=info
Restart=always
RestartSec=3
User=shawn
Group=dialout
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now device-reporter.service
journalctl -u device-reporter.service -f # follow the logs

To upgrade: sudo systemctl stop device-reporter, scp the new binary over, then sudo systemctl start device-reporter. Copying while the service runs fails with "text file busy" because Linux will not overwrite an executable that is in use.

DR_BIND=127.0.0.1 means only nginx on the Pi can reach it, which is what you want once the next section is done. For testing without nginx, use 0.0.0.0:8080.

Optional basic security improvements

Tailscale

I installed Tailscale because it just works. It encrypts everything with WireGuard and gives easy access control: any device on the tailnet can reach the Pi from anywhere; nothing else can. The Tailscale admin page generates the install command:

curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up --auth-key=tskey-auth-.....
sudo tailscale set --auto-update

nginx

Forward :80 to the service so no port is needed in the URL, and so TLS can be added later.

sudo apt install nginx -y
sudo rm /etc/nginx/sites-enabled/default
sudo nano /etc/nginx/sites-available/device-reporter
server{listen80;server_name _;location / {proxy_passhttp://127.0.0.1:8080;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme; # WebSocketproxy_http_version 1.1;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade"; # The service pings every 20 s; this is just belt and braces for quiet nights.proxy_read_timeout1h;}}
sudo ln -s /etc/nginx/sites-available/device-reporter /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Now http://scale-pi/ works from any tailnet device.

UFW firewall

Admit SSH and HTTP only over Tailscale:

sudo apt install ufw -y
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0 to any port 22
sudo ufw allow in on tailscale0 to any port 80
sudo ufw allow in on tailscale0 to any port 443
sudo ufw allow 41641/udp # helps Tailscale connect peer-to-peer
sudo ufw status numbered
sudo ufw enable
sudo service ssh restart

Ultimate test: the page and SSH work with Tailscale running on your PC and fail with it quit. That is a zero-trust setup: only authorized devices can reach the Pi at all.


Next: into the chart

The plan is for the EMR client (front_desk, an egui app that already speaks WebSocket) to subscribe to /ws, and when an observation arrives while a chart is open, show it as a pending vital: "Weight 184.5 lb from the Room 2 scale, 12 s ago. Accept into this chart?" The clinician confirms it is the right person (not their kid playing on the scale) and the client writes a FHIR Observation with device provenance. Nothing is charted automatically.

Longer term the Pi should post observations to the FHIR server instead, which gives a device registry, room-to-device mapping, an audit trail, and a pending queue that survives client restarts.

Contributing

Contributions are welcome. Add a driver for your device; a raw capture from sniff --capture makes a great unit test fixture.

License

MIT. You are free to use, modify, and distribute this software, provided that proper attribution is given.

Acknowledgments

  • Health o meter (Pelstar) for hardware that has worked for 10+ years and for publishing their serial protocol.
  • The serialport, axum, tokio and jiff crates.

About

This project interfaces with an electronic scale via USB and provides real-time weight updates through a web interface and an API.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Device Reporter (formerly ScaleReporter)

A small Rust service that reads clinic devices over USB serial and publishes their results over HTTP and WebSocket, so a weight (and soon a blood pressure, a height, a urinalysis, a hemoglobin) can flow into the chart without anyone retyping it.

It started life as a Python script for one scale. It is now a generic reporter with a driver per device. Drivers so far: the Health o meter large-platform scale (1100 / 2000 series, "L" and "E" serial versions) over its CP210x USB-to-UART option and the McKesson Consult 120 urine analyzer over its USB serial port. Adding a device means writing one driver module; everything else (detection, hot-plug, the API, the page) is shared.


Current setup at my office:

current-setup-scale.jpg

current-setup-scale-2.jpg

Navigating to the Web UI

Scale Web UI

device-reporter-scale.png

Urinalysis UI

device-reporter-urinalysis.png

What it does

  • Auto-detects devices. Serial ports are scanned every few seconds and matched to drivers by USB vendor/product ID. Unplug and replug at will.
  • One event per result, not one per packet. The scale streams the same locked weight every second while someone stands on it. The driver coalesces that into a single observation when they step off. A different weight (a child hopping on after a parent) starts a new one.
  • Plausibility flags for the clinician.below_minimum for a bag or a foot on the platform, single_packet when the scale only re-displayed a previous weight (RECALL/UNITS button).
  • FHIR-ready output. Every result carries LOINC codes and UCUM units, so the EMR can build Observation resources without device-specific knowledge.
  • Live page, REST API, and a WebSocket stream with reconnection and keep-alive pings.
  • list and sniff subcommands for reverse-engineering the next device's protocol.
  • Single static binary, cross-compiled for the Raspberry Pi Zero W. No Python, no venv, no driver install on Linux.

HIPAA / Security

This is a work in progress and does not by itself protect health information. It serves plain HTTP on the address you bind it to, with no authentication. Results contain a weight, timestamps, and whatever ID was typed on the device keypad.

Mitigations used here: bind to loopback and put nginx in front; run the Pi on Tailscale and let UFW admit only the tailnet (see below); keep logs at INFO, which never print device-entered IDs. Extreme care must be used in order to avoid HIPAA violations. You have been warned; this project and its contributors accept no responsibility for how it is deployed.


Transferring it to the Raspberry Pi:

cd PycharmProject/ScaleReporter/target/pi/arm-unknown-linux-gnueabihf/release
scp device-reporter shawn@bmpc-kent-scale:device-reporter

Caveat when updating: Linux will not overwrite a binary that is running (scp: dest open "device-reporter": Failure, "text file busy"). Stop the service first, copy, then start it again:

ssh shawn@bmpc-kent-scale sudo systemctl stop device-reporter
scp device-reporter shawn@bmpc-kent-scale:device-reporter
ssh shawn@bmpc-kent-scale sudo systemctl start device-reporter

Running it

cargo run --release # serve on 127.0.0.1:8080, auto-detect devices
cargo run --release -- --demo # no hardware: a simulated scale weighs a visitor every 15 s
cargo run --release -- list # serial ports with VID:PID, serial number, product
cargo run --release -- sniff COM5 # hex + text dump of whatever COM5 sends

Open http://localhost:8080/ and step on the scale.

Every flag has an environment variable (DR_*); run device-reporter --help. The ones you will actually use:

FlagEnvDefaultPurpose
--bindDR_BIND127.0.0.1:8080Listen address. Use 0.0.0.0:8080 to reach it from another machine without nginx.
--assign PORT=DRIVERDR_ASSIGNForce a driver onto a port, e.g. /dev/scale=healthometer_scale.
--fallback-driverDR_FALLBACK_DRIVERDriver for /dev/ttyUSB* or /dev/ttyACM* ports that expose no USB descriptors. Normally unnecessary; the CP210x is recognised by VID:PID.
--cors-originDR_CORS_ORIGINBrowser origins allowed to call the API cross-origin; needed for the WASM build of the EMR client.
--hostDR_HOSThostnameName reported as host and used in device IDs.
--scale-quiet-msDR_SCALE_QUIET_MS2500Silence that ends a weigh-in.
--scale-min-weight-kgDR_SCALE_MIN_WEIGHT_KG1Below this the result is flagged below_minimum.

On Windows the scale needs the Silicon Labs CP210x driver. Linux has it built in.

API

RouteReturns
GET /The status page.
GET /api/statusProcess info plus every device seen since start.
GET /api/devicesJust the device list.
GET /api/latest[?device=ID]Most recent observation, optionally from one device. 404 until there is one.
GET /api/observations[?device=ID&limit=N]Recent observations, newest first.
GET /wsLive event stream (below).

Events

Every WebSocket message is JSON with v (wire version, currently 1) and type. On connect you get a server snapshot and the latest observation from each device, then live events.

// server: sent once on connect
{"v":1,"type":"server","host":"scale-pi","version":"0.2.0","started_at":"","devices":[]}
// device: a device connected, disconnected, or started/stopped measuring
{"v":1,"type":"device","id":"scale-pi-dev-ttyUSB0","kind":"healthometer_scale",
"display_name":"Health o meter scale","port":"/dev/ttyUSB0","connected":true,
"last_error":null,"last_data_at":"","active":true}
// reading: provisional, once per second while someone is on the scale
{"v":1,"type":"reading","device_id":"","device_kind":"healthometer_scale","at":"",
"subject_hint":null,"components":[{"code":"29463-7","display":"Body weight","value":184.5,"unit":"[lb_av]"}]}
// observation: one completed result
{"v":1,"type":"observation","id":"d094ff0f-…","device_id":"","device_kind":"healthometer_scale",
"captured_at":"","completed_at":"","subject_hint":null,
"components":[
{"code":"29463-7","display":"Body weight","value":72.4,"unit":"kg"},
{"code":"8302-2","display":"Body height","value":178.0,"unit":"cm"},
{"code":"39156-5","display":"Body mass index","value":22.9,"unit":"kg/m2"}],
"flags":[],"packets":5}

components[].code is LOINC; unit is UCUM (kg, [lb_av], cm, [in_i], kg/m2, later mm[Hg], g/dL, …). value is a number or, for coded results such as a urinalysis "trace", a string. subject_hint is whatever ID the device itself carried (the scale keypad); it is a hint for the clinician, never an identity. id is random per observation so the EMR can accept or discard exactly one.

How the scale driver works

The protocol (see reference/HealthometerProf.CommunicationProtocols*.pdf) is 9600 8N1 and each packet looks like this, with no newline:

<ESC>R<ESC>I1234567890<ESC>W184.5<ESC>H84.0<ESC>B24.1<ESC>T0.0<ESC>Nm<ESC>E

src/drivers/healthometer/protocol.rs frames on the <ESC>E terminator, anchors on the last R field so a fragment from connecting mid-stream can never be glued onto the next packet and misread as its weight, and refuses packets with no weight or no units. session.rs turns the once-per-second stream into one result. Both are pure and unit-tested against the packets printed in the PDF.

Adding a device

  1. Find it: device-reporter list shows VID:PID and product strings.
  2. Learn its protocol: device-reporter sniff COM7 --baud 9600 (and --parity, --data-bits, --send-hex "1b 52" for devices that need a request). --capture file.bin saves the raw bytes for a unit test.
  3. Create src/drivers/<device>/mod.rs implementing Driver (how to recognise the port and its line settings) and DeviceSession (bytes in, Output::Live / Output::Complete out). Look at the healthometer driver.
  4. Register it in driver::registry.

Devices queued up: Welch Allyn Spot Vital Signs LXi, Detecto sonar stadiometer, hemoglobin meter. Findings so far, including which port each device actually talks on, are in docs/devices.md.

Project layout

├── Cargo.toml
├── src/
│ ├── main.rs CLI (serve / list / sniff), wiring
│ ├── model.rs Observation, Component, DeviceStatus, Event: the JSON contract
│ ├── driver.rs Driver and DeviceSession traits, port matching, registry
│ ├── drivers/
│ │ ├── healthometer/ protocol.rs (framing, parsing), session.rs (coalescing), mod.rs (driver)
│ │ └── consult120.rs urine analyzer: STX/ETX text report to LOINC components
│ ├── manager.rs port scanning, driver pairing, hot-plug, one thread per device
│ ├── serial.rs the blocking connection loop shared by every serial driver
│ ├── state.rs shared state and the broadcast channel
│ ├── web.rs axum routes and the WebSocket
│ ├── sniff.rs `list` and `sniff`
│ └── demo.rs a fake scale for `--demo`
├── static/index.html the status page, embedded in the binary
├── docs/devices.md per-device notes: what is known, what was tried, what is next
└── reference/ the manufacturer protocol PDF, wiring photos, screenshots

cargo test runs 45 unit tests, none of which need hardware. cargo clippy --all-targets is clean under the strict lint set in Cargo.toml (no unwrap, expect, panic or indexing in non-test code).


Raspberry Pi deployment

Background: I am a Family Physician and love tinkering, programming, teaching and learning. I like when my hobbies can intersect with my profession as well. This is the permanently installed Raspberry Pi that lives behind the scale, documented so I can rebuild it if it is ever lost, and so anyone else can try it.

Audience: someone who knows what a terminal is but may not be overly familiar with Linux.

Consider trying the project from your laptop first (cargo run --release -- --bind 0.0.0.0:8080) to see that your scale is compatible before buying anything.

Parts list

  • Raspberry Pi Zero W (32-bit, ARMv6), which I use, or a Raspberry Pi Zero 2 W (~$15). Any Pi works; this is a stupidly low load, and the Zero draws only about 0.6-1.2 W.
  • Case for the Pi. I 3D printed one.
  • Micro-USB cable for power, or a USB power-only cable with switch.
  • MicroSD card, at least 1 GB.
  • Micro-USB to USB-B cable. Hard to find; I bought these on Amazon. A USB Mini-to-A adapter also works, or use a full-size Pi.
  • A scale with the connectivity option. Hopefully you have this already if you are reading this.

Scale label

Image the Pi (headless)

Generate an SSH key pair

Set up an SSH key before imaging so password logins can be disabled. On Windows I use PuTTY; PuTTYgen, included in the download, generates the key. Save it somewhere safe; you will paste the public key into the imager next.

PuTTYgen

Write Raspberry Pi OS

Install Raspberry Pi OS (Lite is fine) with Raspberry Pi Imager. In the settings, choose a hostname (I use scale-pi), enter the Wi-Fi password, enable SSH, and paste the public key.

Raspberry Pi Imager

Save the PuTTY session

  • Host name: scale-pi.local
  • Connection → SSH → Auth → Credentials: your .ppk file
  • Connection → Data: your username
  • Session: give it a name and Save

First boot and login

Pop the card in, power up, wait a minute, open the saved PuTTY session, accept the host key.

PuTTY alert

Hint: right-clicking the terminal pastes.

Update everything, then reboot:

sudo apt update && sudo apt upgrade -y
sudo raspi-config # expand filesystem, set logging to volatile
sudo shutdown -r now

Build the binary for the Pi (Or skip this section and download the release)

Cross-compile on your PC; the Pi Zero would take an age to compile Rust. From WSL (Ubuntu), with Rust, zig and cargo install cargo-zigbuild, once:

cd /mnt/c/Users/<you>/PycharmProject/ScaleReporter
rustup target add arm-unknown-linux-gnueabihf # 32-bit: Pi Zero, Zero W, Pi 1 (ARMv6)
rustup target add aarch64-unknown-linux-gnu # 64-bit: Zero 2 W, Pi 3/4/5 on 64-bit Raspberry Pi OS

Then, per release:

# Pi Zero / Zero W (32-bit)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target arm-unknown-linux-gnueabihf.2.31
# -> target/pi/arm-unknown-linux-gnueabihf/release/device-reporter# Pi Zero 2 W, Pi 3/4/5 (64-bit OS)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.31
# -> target/pi/aarch64-unknown-linux-gnu/release/device-reporter

Pick by the OS you imaged, not only the board: a Zero 2 W running the 32-bit Raspberry Pi OS needs the 32-bit binary. uname -m on the Pi says armv6l/armv7l for 32-bit and aarch64 for 64-bit. The .2.31 pins glibc 2.31 (Raspberry Pi OS Bullseye), which also runs on Bookworm (2.36) and newer. Each binary is about 2 MB with no dependencies beyond glibc. CARGO_TARGET_DIR=target/pi keeps the WSL build out of the Windows target/ directory.

Copy it over with scp as shown in Transferring it to the Raspberry Pi above.

Plug in and test

Plug the scale into the Pi (the Zero's inner micro-USB port is data; the outer one is power only) and run the binary:

~/device-reporter --bind 0.0.0.0:8080

Within a few seconds the log shows opening device ... port=/dev/ttyUSB0 driver=healthometer_scale. The CP210x bridge is recognized by its USB vendor/product ID, so no port name or driver flag is needed, and the default Raspberry Pi OS user is already in the dialout group. If a port ever shows up as "no matching driver", ~/device-reporter list shows what the OS knows about it and --assign /dev/ttyUSB0=healthometer_scale forces the pairing.

Browse to http://.local:8080/, step on the scale, Ctrl-C when happy.

Run it as a service

sudo nano /etc/systemd/system/device-reporter.service
[Unit]
Description=Device Reporter (clinic USB devices)
After=network.target
[Service]
ExecStart=/home/shawn/device-reporter
WorkingDirectory=/home/shawn
Environment=DR_BIND=127.0.0.1:8080
Environment=RUST_LOG=info
Restart=always
RestartSec=3
User=shawn
Group=dialout
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now device-reporter.service
journalctl -u device-reporter.service -f # follow the logs

To upgrade: sudo systemctl stop device-reporter, scp the new binary over, then sudo systemctl start device-reporter. Copying while the service runs fails with "text file busy" because Linux will not overwrite an executable that is in use.

DR_BIND=127.0.0.1 means only nginx on the Pi can reach it, which is what you want once the next section is done. For testing without nginx, use 0.0.0.0:8080.

Optional basic security improvements

Tailscale

I installed Tailscale because it just works. It encrypts everything with WireGuard and gives easy access control: any device on the tailnet can reach the Pi from anywhere; nothing else can. The Tailscale admin page generates the install command:

curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up --auth-key=tskey-auth-.....
sudo tailscale set --auto-update

nginx

Forward :80 to the service so no port is needed in the URL, and so TLS can be added later.

sudo apt install nginx -y
sudo rm /etc/nginx/sites-enabled/default
sudo nano /etc/nginx/sites-available/device-reporter
server{listen80;server_name _;location / {proxy_passhttp://127.0.0.1:8080;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme; # WebSocketproxy_http_version 1.1;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade"; # The service pings every 20 s; this is just belt and braces for quiet nights.proxy_read_timeout1h;}}
sudo ln -s /etc/nginx/sites-available/device-reporter /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Now http://scale-pi/ works from any tailnet device.

UFW firewall

Admit SSH and HTTP only over Tailscale:

sudo apt install ufw -y
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0 to any port 22
sudo ufw allow in on tailscale0 to any port 80
sudo ufw allow in on tailscale0 to any port 443
sudo ufw allow 41641/udp # helps Tailscale connect peer-to-peer
sudo ufw status numbered
sudo ufw enable
sudo service ssh restart

Ultimate test: the page and SSH work with Tailscale running on your PC and fail with it quit. That is a zero-trust setup: only authorized devices can reach the Pi at all.


Next: into the chart

The plan is for the EMR client (front_desk, an egui app that already speaks WebSocket) to subscribe to /ws, and when an observation arrives while a chart is open, show it as a pending vital: "Weight 184.5 lb from the Room 2 scale, 12 s ago. Accept into this chart?" The clinician confirms it is the right person (not their kid playing on the scale) and the client writes a FHIR Observation with device provenance. Nothing is charted automatically.

Longer term the Pi should post observations to the FHIR server instead, which gives a device registry, room-to-device mapping, an audit trail, and a pending queue that survives client restarts.

Contributing

Contributions are welcome. Add a driver for your device; a raw capture from sniff --capture makes a great unit test fixture.

License

MIT. You are free to use, modify, and distribute this software, provided that proper attribution is given.

Acknowledgments

  • Health o meter (Pelstar) for hardware that has worked for 10+ years and for publishing their serial protocol.
  • The serialport, axum, tokio and jiff crates.

About

This project interfaces with an electronic scale via USB and provides real-time weight updates through a web interface and an API.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Device Reporter (formerly ScaleReporter)

A small Rust service that reads clinic devices over USB serial and publishes their results over HTTP and WebSocket, so a weight (and soon a blood pressure, a height, a urinalysis, a hemoglobin) can flow into the chart without anyone retyping it.

It started life as a Python script for one scale. It is now a generic reporter with a driver per device. Drivers so far: the Health o meter large-platform scale (1100 / 2000 series, "L" and "E" serial versions) over its CP210x USB-to-UART option and the McKesson Consult 120 urine analyzer over its USB serial port. Adding a device means writing one driver module; everything else (detection, hot-plug, the API, the page) is shared.


Current setup at my office:

current-setup-scale.jpg

current-setup-scale-2.jpg

Navigating to the Web UI

Scale Web UI

device-reporter-scale.png

Urinalysis UI

device-reporter-urinalysis.png

What it does

  • Auto-detects devices. Serial ports are scanned every few seconds and matched to drivers by USB vendor/product ID. Unplug and replug at will.
  • One event per result, not one per packet. The scale streams the same locked weight every second while someone stands on it. The driver coalesces that into a single observation when they step off. A different weight (a child hopping on after a parent) starts a new one.
  • Plausibility flags for the clinician.below_minimum for a bag or a foot on the platform, single_packet when the scale only re-displayed a previous weight (RECALL/UNITS button).
  • FHIR-ready output. Every result carries LOINC codes and UCUM units, so the EMR can build Observation resources without device-specific knowledge.
  • Live page, REST API, and a WebSocket stream with reconnection and keep-alive pings.
  • list and sniff subcommands for reverse-engineering the next device's protocol.
  • Single static binary, cross-compiled for the Raspberry Pi Zero W. No Python, no venv, no driver install on Linux.

HIPAA / Security

This is a work in progress and does not by itself protect health information. It serves plain HTTP on the address you bind it to, with no authentication. Results contain a weight, timestamps, and whatever ID was typed on the device keypad.

Mitigations used here: bind to loopback and put nginx in front; run the Pi on Tailscale and let UFW admit only the tailnet (see below); keep logs at INFO, which never print device-entered IDs. Extreme care must be used in order to avoid HIPAA violations. You have been warned; this project and its contributors accept no responsibility for how it is deployed.


Transferring it to the Raspberry Pi:

cd PycharmProject/ScaleReporter/target/pi/arm-unknown-linux-gnueabihf/release
scp device-reporter shawn@bmpc-kent-scale:device-reporter

Caveat when updating: Linux will not overwrite a binary that is running (scp: dest open "device-reporter": Failure, "text file busy"). Stop the service first, copy, then start it again:

ssh shawn@bmpc-kent-scale sudo systemctl stop device-reporter
scp device-reporter shawn@bmpc-kent-scale:device-reporter
ssh shawn@bmpc-kent-scale sudo systemctl start device-reporter

Running it

cargo run --release # serve on 127.0.0.1:8080, auto-detect devices
cargo run --release -- --demo # no hardware: a simulated scale weighs a visitor every 15 s
cargo run --release -- list # serial ports with VID:PID, serial number, product
cargo run --release -- sniff COM5 # hex + text dump of whatever COM5 sends

Open http://localhost:8080/ and step on the scale.

Every flag has an environment variable (DR_*); run device-reporter --help. The ones you will actually use:

FlagEnvDefaultPurpose
--bindDR_BIND127.0.0.1:8080Listen address. Use 0.0.0.0:8080 to reach it from another machine without nginx.
--assign PORT=DRIVERDR_ASSIGNForce a driver onto a port, e.g. /dev/scale=healthometer_scale.
--fallback-driverDR_FALLBACK_DRIVERDriver for /dev/ttyUSB* or /dev/ttyACM* ports that expose no USB descriptors. Normally unnecessary; the CP210x is recognised by VID:PID.
--cors-originDR_CORS_ORIGINBrowser origins allowed to call the API cross-origin; needed for the WASM build of the EMR client.
--hostDR_HOSThostnameName reported as host and used in device IDs.
--scale-quiet-msDR_SCALE_QUIET_MS2500Silence that ends a weigh-in.
--scale-min-weight-kgDR_SCALE_MIN_WEIGHT_KG1Below this the result is flagged below_minimum.

On Windows the scale needs the Silicon Labs CP210x driver. Linux has it built in.

API

RouteReturns
GET /The status page.
GET /api/statusProcess info plus every device seen since start.
GET /api/devicesJust the device list.
GET /api/latest[?device=ID]Most recent observation, optionally from one device. 404 until there is one.
GET /api/observations[?device=ID&limit=N]Recent observations, newest first.
GET /wsLive event stream (below).

Events

Every WebSocket message is JSON with v (wire version, currently 1) and type. On connect you get a server snapshot and the latest observation from each device, then live events.

// server: sent once on connect
{"v":1,"type":"server","host":"scale-pi","version":"0.2.0","started_at":"","devices":[]}
// device: a device connected, disconnected, or started/stopped measuring
{"v":1,"type":"device","id":"scale-pi-dev-ttyUSB0","kind":"healthometer_scale",
"display_name":"Health o meter scale","port":"/dev/ttyUSB0","connected":true,
"last_error":null,"last_data_at":"","active":true}
// reading: provisional, once per second while someone is on the scale
{"v":1,"type":"reading","device_id":"","device_kind":"healthometer_scale","at":"",
"subject_hint":null,"components":[{"code":"29463-7","display":"Body weight","value":184.5,"unit":"[lb_av]"}]}
// observation: one completed result
{"v":1,"type":"observation","id":"d094ff0f-…","device_id":"","device_kind":"healthometer_scale",
"captured_at":"","completed_at":"","subject_hint":null,
"components":[
{"code":"29463-7","display":"Body weight","value":72.4,"unit":"kg"},
{"code":"8302-2","display":"Body height","value":178.0,"unit":"cm"},
{"code":"39156-5","display":"Body mass index","value":22.9,"unit":"kg/m2"}],
"flags":[],"packets":5}

components[].code is LOINC; unit is UCUM (kg, [lb_av], cm, [in_i], kg/m2, later mm[Hg], g/dL, …). value is a number or, for coded results such as a urinalysis "trace", a string. subject_hint is whatever ID the device itself carried (the scale keypad); it is a hint for the clinician, never an identity. id is random per observation so the EMR can accept or discard exactly one.

How the scale driver works

The protocol (see reference/HealthometerProf.CommunicationProtocols*.pdf) is 9600 8N1 and each packet looks like this, with no newline:

<ESC>R<ESC>I1234567890<ESC>W184.5<ESC>H84.0<ESC>B24.1<ESC>T0.0<ESC>Nm<ESC>E

src/drivers/healthometer/protocol.rs frames on the <ESC>E terminator, anchors on the last R field so a fragment from connecting mid-stream can never be glued onto the next packet and misread as its weight, and refuses packets with no weight or no units. session.rs turns the once-per-second stream into one result. Both are pure and unit-tested against the packets printed in the PDF.

Adding a device

  1. Find it: device-reporter list shows VID:PID and product strings.
  2. Learn its protocol: device-reporter sniff COM7 --baud 9600 (and --parity, --data-bits, --send-hex "1b 52" for devices that need a request). --capture file.bin saves the raw bytes for a unit test.
  3. Create src/drivers/<device>/mod.rs implementing Driver (how to recognise the port and its line settings) and DeviceSession (bytes in, Output::Live / Output::Complete out). Look at the healthometer driver.
  4. Register it in driver::registry.

Devices queued up: Welch Allyn Spot Vital Signs LXi, Detecto sonar stadiometer, hemoglobin meter. Findings so far, including which port each device actually talks on, are in docs/devices.md.

Project layout

├── Cargo.toml
├── src/
│ ├── main.rs CLI (serve / list / sniff), wiring
│ ├── model.rs Observation, Component, DeviceStatus, Event: the JSON contract
│ ├── driver.rs Driver and DeviceSession traits, port matching, registry
│ ├── drivers/
│ │ ├── healthometer/ protocol.rs (framing, parsing), session.rs (coalescing), mod.rs (driver)
│ │ └── consult120.rs urine analyzer: STX/ETX text report to LOINC components
│ ├── manager.rs port scanning, driver pairing, hot-plug, one thread per device
│ ├── serial.rs the blocking connection loop shared by every serial driver
│ ├── state.rs shared state and the broadcast channel
│ ├── web.rs axum routes and the WebSocket
│ ├── sniff.rs `list` and `sniff`
│ └── demo.rs a fake scale for `--demo`
├── static/index.html the status page, embedded in the binary
├── docs/devices.md per-device notes: what is known, what was tried, what is next
└── reference/ the manufacturer protocol PDF, wiring photos, screenshots

cargo test runs 45 unit tests, none of which need hardware. cargo clippy --all-targets is clean under the strict lint set in Cargo.toml (no unwrap, expect, panic or indexing in non-test code).


Raspberry Pi deployment

Background: I am a Family Physician and love tinkering, programming, teaching and learning. I like when my hobbies can intersect with my profession as well. This is the permanently installed Raspberry Pi that lives behind the scale, documented so I can rebuild it if it is ever lost, and so anyone else can try it.

Audience: someone who knows what a terminal is but may not be overly familiar with Linux.

Consider trying the project from your laptop first (cargo run --release -- --bind 0.0.0.0:8080) to see that your scale is compatible before buying anything.

Parts list

  • Raspberry Pi Zero W (32-bit, ARMv6), which I use, or a Raspberry Pi Zero 2 W (~$15). Any Pi works; this is a stupidly low load, and the Zero draws only about 0.6-1.2 W.
  • Case for the Pi. I 3D printed one.
  • Micro-USB cable for power, or a USB power-only cable with switch.
  • MicroSD card, at least 1 GB.
  • Micro-USB to USB-B cable. Hard to find; I bought these on Amazon. A USB Mini-to-A adapter also works, or use a full-size Pi.
  • A scale with the connectivity option. Hopefully you have this already if you are reading this.

Scale label

Image the Pi (headless)

Generate an SSH key pair

Set up an SSH key before imaging so password logins can be disabled. On Windows I use PuTTY; PuTTYgen, included in the download, generates the key. Save it somewhere safe; you will paste the public key into the imager next.

PuTTYgen

Write Raspberry Pi OS

Install Raspberry Pi OS (Lite is fine) with Raspberry Pi Imager. In the settings, choose a hostname (I use scale-pi), enter the Wi-Fi password, enable SSH, and paste the public key.

Raspberry Pi Imager

Save the PuTTY session

  • Host name: scale-pi.local
  • Connection → SSH → Auth → Credentials: your .ppk file
  • Connection → Data: your username
  • Session: give it a name and Save

First boot and login

Pop the card in, power up, wait a minute, open the saved PuTTY session, accept the host key.

PuTTY alert

Hint: right-clicking the terminal pastes.

Update everything, then reboot:

sudo apt update && sudo apt upgrade -y
sudo raspi-config # expand filesystem, set logging to volatile
sudo shutdown -r now

Build the binary for the Pi (Or skip this section and download the release)

Cross-compile on your PC; the Pi Zero would take an age to compile Rust. From WSL (Ubuntu), with Rust, zig and cargo install cargo-zigbuild, once:

cd /mnt/c/Users/<you>/PycharmProject/ScaleReporter
rustup target add arm-unknown-linux-gnueabihf # 32-bit: Pi Zero, Zero W, Pi 1 (ARMv6)
rustup target add aarch64-unknown-linux-gnu # 64-bit: Zero 2 W, Pi 3/4/5 on 64-bit Raspberry Pi OS

Then, per release:

# Pi Zero / Zero W (32-bit)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target arm-unknown-linux-gnueabihf.2.31
# -> target/pi/arm-unknown-linux-gnueabihf/release/device-reporter# Pi Zero 2 W, Pi 3/4/5 (64-bit OS)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.31
# -> target/pi/aarch64-unknown-linux-gnu/release/device-reporter

Pick by the OS you imaged, not only the board: a Zero 2 W running the 32-bit Raspberry Pi OS needs the 32-bit binary. uname -m on the Pi says armv6l/armv7l for 32-bit and aarch64 for 64-bit. The .2.31 pins glibc 2.31 (Raspberry Pi OS Bullseye), which also runs on Bookworm (2.36) and newer. Each binary is about 2 MB with no dependencies beyond glibc. CARGO_TARGET_DIR=target/pi keeps the WSL build out of the Windows target/ directory.

Copy it over with scp as shown in Transferring it to the Raspberry Pi above.

Plug in and test

Plug the scale into the Pi (the Zero's inner micro-USB port is data; the outer one is power only) and run the binary:

~/device-reporter --bind 0.0.0.0:8080

Within a few seconds the log shows opening device ... port=/dev/ttyUSB0 driver=healthometer_scale. The CP210x bridge is recognized by its USB vendor/product ID, so no port name or driver flag is needed, and the default Raspberry Pi OS user is already in the dialout group. If a port ever shows up as "no matching driver", ~/device-reporter list shows what the OS knows about it and --assign /dev/ttyUSB0=healthometer_scale forces the pairing.

Browse to http://.local:8080/, step on the scale, Ctrl-C when happy.

Run it as a service

sudo nano /etc/systemd/system/device-reporter.service
[Unit]
Description=Device Reporter (clinic USB devices)
After=network.target
[Service]
ExecStart=/home/shawn/device-reporter
WorkingDirectory=/home/shawn
Environment=DR_BIND=127.0.0.1:8080
Environment=RUST_LOG=info
Restart=always
RestartSec=3
User=shawn
Group=dialout
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now device-reporter.service
journalctl -u device-reporter.service -f # follow the logs

To upgrade: sudo systemctl stop device-reporter, scp the new binary over, then sudo systemctl start device-reporter. Copying while the service runs fails with "text file busy" because Linux will not overwrite an executable that is in use.

DR_BIND=127.0.0.1 means only nginx on the Pi can reach it, which is what you want once the next section is done. For testing without nginx, use 0.0.0.0:8080.

Optional basic security improvements

Tailscale

I installed Tailscale because it just works. It encrypts everything with WireGuard and gives easy access control: any device on the tailnet can reach the Pi from anywhere; nothing else can. The Tailscale admin page generates the install command:

curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up --auth-key=tskey-auth-.....
sudo tailscale set --auto-update

nginx

Forward :80 to the service so no port is needed in the URL, and so TLS can be added later.

sudo apt install nginx -y
sudo rm /etc/nginx/sites-enabled/default
sudo nano /etc/nginx/sites-available/device-reporter
server{listen80;server_name _;location / {proxy_passhttp://127.0.0.1:8080;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme; # WebSocketproxy_http_version 1.1;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade"; # The service pings every 20 s; this is just belt and braces for quiet nights.proxy_read_timeout1h;}}
sudo ln -s /etc/nginx/sites-available/device-reporter /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Now http://scale-pi/ works from any tailnet device.

UFW firewall

Admit SSH and HTTP only over Tailscale:

sudo apt install ufw -y
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0 to any port 22
sudo ufw allow in on tailscale0 to any port 80
sudo ufw allow in on tailscale0 to any port 443
sudo ufw allow 41641/udp # helps Tailscale connect peer-to-peer
sudo ufw status numbered
sudo ufw enable
sudo service ssh restart

Ultimate test: the page and SSH work with Tailscale running on your PC and fail with it quit. That is a zero-trust setup: only authorized devices can reach the Pi at all.


Next: into the chart

The plan is for the EMR client (front_desk, an egui app that already speaks WebSocket) to subscribe to /ws, and when an observation arrives while a chart is open, show it as a pending vital: "Weight 184.5 lb from the Room 2 scale, 12 s ago. Accept into this chart?" The clinician confirms it is the right person (not their kid playing on the scale) and the client writes a FHIR Observation with device provenance. Nothing is charted automatically.

Longer term the Pi should post observations to the FHIR server instead, which gives a device registry, room-to-device mapping, an audit trail, and a pending queue that survives client restarts.

Contributing

Contributions are welcome. Add a driver for your device; a raw capture from sniff --capture makes a great unit test fixture.

License

MIT. You are free to use, modify, and distribute this software, provided that proper attribution is given.

Acknowledgments

  • Health o meter (Pelstar) for hardware that has worked for 10+ years and for publishing their serial protocol.
  • The serialport, axum, tokio and jiff crates.

About

This project interfaces with an electronic scale via USB and provides real-time weight updates through a web interface and an API.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Latest commit

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Device Reporter (formerly ScaleReporter)

A small Rust service that reads clinic devices over USB serial and publishes their results over HTTP and WebSocket, so a weight (and soon a blood pressure, a height, a urinalysis, a hemoglobin) can flow into the chart without anyone retyping it.

It started life as a Python script for one scale. It is now a generic reporter with a driver per device. Drivers so far: the Health o meter large-platform scale (1100 / 2000 series, "L" and "E" serial versions) over its CP210x USB-to-UART option and the McKesson Consult 120 urine analyzer over its USB serial port. Adding a device means writing one driver module; everything else (detection, hot-plug, the API, the page) is shared.


Current setup at my office:

current-setup-scale.jpg

current-setup-scale-2.jpg

Navigating to the Web UI

Scale Web UI

device-reporter-scale.png

Urinalysis UI

device-reporter-urinalysis.png

What it does

  • Auto-detects devices. Serial ports are scanned every few seconds and matched to drivers by USB vendor/product ID. Unplug and replug at will.
  • One event per result, not one per packet. The scale streams the same locked weight every second while someone stands on it. The driver coalesces that into a single observation when they step off. A different weight (a child hopping on after a parent) starts a new one.
  • Plausibility flags for the clinician.below_minimum for a bag or a foot on the platform, single_packet when the scale only re-displayed a previous weight (RECALL/UNITS button).
  • FHIR-ready output. Every result carries LOINC codes and UCUM units, so the EMR can build Observation resources without device-specific knowledge.
  • Live page, REST API, and a WebSocket stream with reconnection and keep-alive pings.
  • list and sniff subcommands for reverse-engineering the next device's protocol.
  • Single static binary, cross-compiled for the Raspberry Pi Zero W. No Python, no venv, no driver install on Linux.

HIPAA / Security

This is a work in progress and does not by itself protect health information. It serves plain HTTP on the address you bind it to, with no authentication. Results contain a weight, timestamps, and whatever ID was typed on the device keypad.

Mitigations used here: bind to loopback and put nginx in front; run the Pi on Tailscale and let UFW admit only the tailnet (see below); keep logs at INFO, which never print device-entered IDs. Extreme care must be used in order to avoid HIPAA violations. You have been warned; this project and its contributors accept no responsibility for how it is deployed.


Transferring it to the Raspberry Pi:

cd PycharmProject/ScaleReporter/target/pi/arm-unknown-linux-gnueabihf/release
scp device-reporter shawn@bmpc-kent-scale:device-reporter

Caveat when updating: Linux will not overwrite a binary that is running (scp: dest open "device-reporter": Failure, "text file busy"). Stop the service first, copy, then start it again:

ssh shawn@bmpc-kent-scale sudo systemctl stop device-reporter
scp device-reporter shawn@bmpc-kent-scale:device-reporter
ssh shawn@bmpc-kent-scale sudo systemctl start device-reporter

Running it

cargo run --release # serve on 127.0.0.1:8080, auto-detect devices
cargo run --release -- --demo # no hardware: a simulated scale weighs a visitor every 15 s
cargo run --release -- list # serial ports with VID:PID, serial number, product
cargo run --release -- sniff COM5 # hex + text dump of whatever COM5 sends

Open http://localhost:8080/ and step on the scale.

Every flag has an environment variable (DR_*); run device-reporter --help. The ones you will actually use:

FlagEnvDefaultPurpose
--bindDR_BIND127.0.0.1:8080Listen address. Use 0.0.0.0:8080 to reach it from another machine without nginx.
--assign PORT=DRIVERDR_ASSIGNForce a driver onto a port, e.g. /dev/scale=healthometer_scale.
--fallback-driverDR_FALLBACK_DRIVERDriver for /dev/ttyUSB* or /dev/ttyACM* ports that expose no USB descriptors. Normally unnecessary; the CP210x is recognised by VID:PID.
--cors-originDR_CORS_ORIGINBrowser origins allowed to call the API cross-origin; needed for the WASM build of the EMR client.
--hostDR_HOSThostnameName reported as host and used in device IDs.
--scale-quiet-msDR_SCALE_QUIET_MS2500Silence that ends a weigh-in.
--scale-min-weight-kgDR_SCALE_MIN_WEIGHT_KG1Below this the result is flagged below_minimum.

On Windows the scale needs the Silicon Labs CP210x driver. Linux has it built in.

API

RouteReturns
GET /The status page.
GET /api/statusProcess info plus every device seen since start.
GET /api/devicesJust the device list.
GET /api/latest[?device=ID]Most recent observation, optionally from one device. 404 until there is one.
GET /api/observations[?device=ID&limit=N]Recent observations, newest first.
GET /wsLive event stream (below).

Events

Every WebSocket message is JSON with v (wire version, currently 1) and type. On connect you get a server snapshot and the latest observation from each device, then live events.

// server: sent once on connect
{"v":1,"type":"server","host":"scale-pi","version":"0.2.0","started_at":"","devices":[]}
// device: a device connected, disconnected, or started/stopped measuring
{"v":1,"type":"device","id":"scale-pi-dev-ttyUSB0","kind":"healthometer_scale",
"display_name":"Health o meter scale","port":"/dev/ttyUSB0","connected":true,
"last_error":null,"last_data_at":"","active":true}
// reading: provisional, once per second while someone is on the scale
{"v":1,"type":"reading","device_id":"","device_kind":"healthometer_scale","at":"",
"subject_hint":null,"components":[{"code":"29463-7","display":"Body weight","value":184.5,"unit":"[lb_av]"}]}
// observation: one completed result
{"v":1,"type":"observation","id":"d094ff0f-…","device_id":"","device_kind":"healthometer_scale",
"captured_at":"","completed_at":"","subject_hint":null,
"components":[
{"code":"29463-7","display":"Body weight","value":72.4,"unit":"kg"},
{"code":"8302-2","display":"Body height","value":178.0,"unit":"cm"},
{"code":"39156-5","display":"Body mass index","value":22.9,"unit":"kg/m2"}],
"flags":[],"packets":5}

components[].code is LOINC; unit is UCUM (kg, [lb_av], cm, [in_i], kg/m2, later mm[Hg], g/dL, …). value is a number or, for coded results such as a urinalysis "trace", a string. subject_hint is whatever ID the device itself carried (the scale keypad); it is a hint for the clinician, never an identity. id is random per observation so the EMR can accept or discard exactly one.

How the scale driver works

The protocol (see reference/HealthometerProf.CommunicationProtocols*.pdf) is 9600 8N1 and each packet looks like this, with no newline:

<ESC>R<ESC>I1234567890<ESC>W184.5<ESC>H84.0<ESC>B24.1<ESC>T0.0<ESC>Nm<ESC>E

src/drivers/healthometer/protocol.rs frames on the <ESC>E terminator, anchors on the last R field so a fragment from connecting mid-stream can never be glued onto the next packet and misread as its weight, and refuses packets with no weight or no units. session.rs turns the once-per-second stream into one result. Both are pure and unit-tested against the packets printed in the PDF.

Adding a device

  1. Find it: device-reporter list shows VID:PID and product strings.
  2. Learn its protocol: device-reporter sniff COM7 --baud 9600 (and --parity, --data-bits, --send-hex "1b 52" for devices that need a request). --capture file.bin saves the raw bytes for a unit test.
  3. Create src/drivers/<device>/mod.rs implementing Driver (how to recognise the port and its line settings) and DeviceSession (bytes in, Output::Live / Output::Complete out). Look at the healthometer driver.
  4. Register it in driver::registry.

Devices queued up: Welch Allyn Spot Vital Signs LXi, Detecto sonar stadiometer, hemoglobin meter. Findings so far, including which port each device actually talks on, are in docs/devices.md.

Project layout

├── Cargo.toml
├── src/
│ ├── main.rs CLI (serve / list / sniff), wiring
│ ├── model.rs Observation, Component, DeviceStatus, Event: the JSON contract
│ ├── driver.rs Driver and DeviceSession traits, port matching, registry
│ ├── drivers/
│ │ ├── healthometer/ protocol.rs (framing, parsing), session.rs (coalescing), mod.rs (driver)
│ │ └── consult120.rs urine analyzer: STX/ETX text report to LOINC components
│ ├── manager.rs port scanning, driver pairing, hot-plug, one thread per device
│ ├── serial.rs the blocking connection loop shared by every serial driver
│ ├── state.rs shared state and the broadcast channel
│ ├── web.rs axum routes and the WebSocket
│ ├── sniff.rs `list` and `sniff`
│ └── demo.rs a fake scale for `--demo`
├── static/index.html the status page, embedded in the binary
├── docs/devices.md per-device notes: what is known, what was tried, what is next
└── reference/ the manufacturer protocol PDF, wiring photos, screenshots

cargo test runs 45 unit tests, none of which need hardware. cargo clippy --all-targets is clean under the strict lint set in Cargo.toml (no unwrap, expect, panic or indexing in non-test code).


Raspberry Pi deployment

Background: I am a Family Physician and love tinkering, programming, teaching and learning. I like when my hobbies can intersect with my profession as well. This is the permanently installed Raspberry Pi that lives behind the scale, documented so I can rebuild it if it is ever lost, and so anyone else can try it.

Audience: someone who knows what a terminal is but may not be overly familiar with Linux.

Consider trying the project from your laptop first (cargo run --release -- --bind 0.0.0.0:8080) to see that your scale is compatible before buying anything.

Parts list

  • Raspberry Pi Zero W (32-bit, ARMv6), which I use, or a Raspberry Pi Zero 2 W (~$15). Any Pi works; this is a stupidly low load, and the Zero draws only about 0.6-1.2 W.
  • Case for the Pi. I 3D printed one.
  • Micro-USB cable for power, or a USB power-only cable with switch.
  • MicroSD card, at least 1 GB.
  • Micro-USB to USB-B cable. Hard to find; I bought these on Amazon. A USB Mini-to-A adapter also works, or use a full-size Pi.
  • A scale with the connectivity option. Hopefully you have this already if you are reading this.

Scale label

Image the Pi (headless)

Generate an SSH key pair

Set up an SSH key before imaging so password logins can be disabled. On Windows I use PuTTY; PuTTYgen, included in the download, generates the key. Save it somewhere safe; you will paste the public key into the imager next.

PuTTYgen

Write Raspberry Pi OS

Install Raspberry Pi OS (Lite is fine) with Raspberry Pi Imager. In the settings, choose a hostname (I use scale-pi), enter the Wi-Fi password, enable SSH, and paste the public key.

Raspberry Pi Imager

Save the PuTTY session

  • Host name: scale-pi.local
  • Connection → SSH → Auth → Credentials: your .ppk file
  • Connection → Data: your username
  • Session: give it a name and Save

First boot and login

Pop the card in, power up, wait a minute, open the saved PuTTY session, accept the host key.

PuTTY alert

Hint: right-clicking the terminal pastes.

Update everything, then reboot:

sudo apt update && sudo apt upgrade -y
sudo raspi-config # expand filesystem, set logging to volatile
sudo shutdown -r now

Build the binary for the Pi (Or skip this section and download the release)

Cross-compile on your PC; the Pi Zero would take an age to compile Rust. From WSL (Ubuntu), with Rust, zig and cargo install cargo-zigbuild, once:

cd /mnt/c/Users/<you>/PycharmProject/ScaleReporter
rustup target add arm-unknown-linux-gnueabihf # 32-bit: Pi Zero, Zero W, Pi 1 (ARMv6)
rustup target add aarch64-unknown-linux-gnu # 64-bit: Zero 2 W, Pi 3/4/5 on 64-bit Raspberry Pi OS

Then, per release:

# Pi Zero / Zero W (32-bit)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target arm-unknown-linux-gnueabihf.2.31
# -> target/pi/arm-unknown-linux-gnueabihf/release/device-reporter# Pi Zero 2 W, Pi 3/4/5 (64-bit OS)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.31
# -> target/pi/aarch64-unknown-linux-gnu/release/device-reporter

Pick by the OS you imaged, not only the board: a Zero 2 W running the 32-bit Raspberry Pi OS needs the 32-bit binary. uname -m on the Pi says armv6l/armv7l for 32-bit and aarch64 for 64-bit. The .2.31 pins glibc 2.31 (Raspberry Pi OS Bullseye), which also runs on Bookworm (2.36) and newer. Each binary is about 2 MB with no dependencies beyond glibc. CARGO_TARGET_DIR=target/pi keeps the WSL build out of the Windows target/ directory.

Copy it over with scp as shown in Transferring it to the Raspberry Pi above.

Plug in and test

Plug the scale into the Pi (the Zero's inner micro-USB port is data; the outer one is power only) and run the binary:

~/device-reporter --bind 0.0.0.0:8080

Within a few seconds the log shows opening device ... port=/dev/ttyUSB0 driver=healthometer_scale. The CP210x bridge is recognized by its USB vendor/product ID, so no port name or driver flag is needed, and the default Raspberry Pi OS user is already in the dialout group. If a port ever shows up as "no matching driver", ~/device-reporter list shows what the OS knows about it and --assign /dev/ttyUSB0=healthometer_scale forces the pairing.

Browse to http://.local:8080/, step on the scale, Ctrl-C when happy.

Run it as a service

sudo nano /etc/systemd/system/device-reporter.service
[Unit]
Description=Device Reporter (clinic USB devices)
After=network.target
[Service]
ExecStart=/home/shawn/device-reporter
WorkingDirectory=/home/shawn
Environment=DR_BIND=127.0.0.1:8080
Environment=RUST_LOG=info
Restart=always
RestartSec=3
User=shawn
Group=dialout
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now device-reporter.service
journalctl -u device-reporter.service -f # follow the logs

To upgrade: sudo systemctl stop device-reporter, scp the new binary over, then sudo systemctl start device-reporter. Copying while the service runs fails with "text file busy" because Linux will not overwrite an executable that is in use.

DR_BIND=127.0.0.1 means only nginx on the Pi can reach it, which is what you want once the next section is done. For testing without nginx, use 0.0.0.0:8080.

Optional basic security improvements

Tailscale

I installed Tailscale because it just works. It encrypts everything with WireGuard and gives easy access control: any device on the tailnet can reach the Pi from anywhere; nothing else can. The Tailscale admin page generates the install command:

curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up --auth-key=tskey-auth-.....
sudo tailscale set --auto-update

nginx

Forward :80 to the service so no port is needed in the URL, and so TLS can be added later.

sudo apt install nginx -y
sudo rm /etc/nginx/sites-enabled/default
sudo nano /etc/nginx/sites-available/device-reporter
server{listen80;server_name _;location / {proxy_passhttp://127.0.0.1:8080;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme; # WebSocketproxy_http_version 1.1;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade"; # The service pings every 20 s; this is just belt and braces for quiet nights.proxy_read_timeout1h;}}
sudo ln -s /etc/nginx/sites-available/device-reporter /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Now http://scale-pi/ works from any tailnet device.

UFW firewall

Admit SSH and HTTP only over Tailscale:

sudo apt install ufw -y
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0 to any port 22
sudo ufw allow in on tailscale0 to any port 80
sudo ufw allow in on tailscale0 to any port 443
sudo ufw allow 41641/udp # helps Tailscale connect peer-to-peer
sudo ufw status numbered
sudo ufw enable
sudo service ssh restart

Ultimate test: the page and SSH work with Tailscale running on your PC and fail with it quit. That is a zero-trust setup: only authorized devices can reach the Pi at all.


Next: into the chart

The plan is for the EMR client (front_desk, an egui app that already speaks WebSocket) to subscribe to /ws, and when an observation arrives while a chart is open, show it as a pending vital: "Weight 184.5 lb from the Room 2 scale, 12 s ago. Accept into this chart?" The clinician confirms it is the right person (not their kid playing on the scale) and the client writes a FHIR Observation with device provenance. Nothing is charted automatically.

Longer term the Pi should post observations to the FHIR server instead, which gives a device registry, room-to-device mapping, an audit trail, and a pending queue that survives client restarts.

Contributing

Contributions are welcome. Add a driver for your device; a raw capture from sniff --capture makes a great unit test fixture.

License

MIT. You are free to use, modify, and distribute this software, provided that proper attribution is given.

Acknowledgments

  • Health o meter (Pelstar) for hardware that has worked for 10+ years and for publishing their serial protocol.
  • The serialport, axum, tokio and jiff crates.

About

This project interfaces with an electronic scale via USB and provides real-time weight updates through a web interface and an API.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Device Reporter (formerly ScaleReporter)

A small Rust service that reads clinic devices over USB serial and publishes their results over HTTP and WebSocket, so a weight (and soon a blood pressure, a height, a urinalysis, a hemoglobin) can flow into the chart without anyone retyping it.

It started life as a Python script for one scale. It is now a generic reporter with a driver per device. Drivers so far: the Health o meter large-platform scale (1100 / 2000 series, "L" and "E" serial versions) over its CP210x USB-to-UART option and the McKesson Consult 120 urine analyzer over its USB serial port. Adding a device means writing one driver module; everything else (detection, hot-plug, the API, the page) is shared.


Current setup at my office:

current-setup-scale.jpg

current-setup-scale-2.jpg

Navigating to the Web UI

Scale Web UI

device-reporter-scale.png

Urinalysis UI

device-reporter-urinalysis.png

What it does

  • Auto-detects devices. Serial ports are scanned every few seconds and matched to drivers by USB vendor/product ID. Unplug and replug at will.
  • One event per result, not one per packet. The scale streams the same locked weight every second while someone stands on it. The driver coalesces that into a single observation when they step off. A different weight (a child hopping on after a parent) starts a new one.
  • Plausibility flags for the clinician.below_minimum for a bag or a foot on the platform, single_packet when the scale only re-displayed a previous weight (RECALL/UNITS button).
  • FHIR-ready output. Every result carries LOINC codes and UCUM units, so the EMR can build Observation resources without device-specific knowledge.
  • Live page, REST API, and a WebSocket stream with reconnection and keep-alive pings.
  • list and sniff subcommands for reverse-engineering the next device's protocol.
  • Single static binary, cross-compiled for the Raspberry Pi Zero W. No Python, no venv, no driver install on Linux.

HIPAA / Security

This is a work in progress and does not by itself protect health information. It serves plain HTTP on the address you bind it to, with no authentication. Results contain a weight, timestamps, and whatever ID was typed on the device keypad.

Mitigations used here: bind to loopback and put nginx in front; run the Pi on Tailscale and let UFW admit only the tailnet (see below); keep logs at INFO, which never print device-entered IDs. Extreme care must be used in order to avoid HIPAA violations. You have been warned; this project and its contributors accept no responsibility for how it is deployed.


Transferring it to the Raspberry Pi:

cd PycharmProject/ScaleReporter/target/pi/arm-unknown-linux-gnueabihf/release
scp device-reporter shawn@bmpc-kent-scale:device-reporter

Caveat when updating: Linux will not overwrite a binary that is running (scp: dest open "device-reporter": Failure, "text file busy"). Stop the service first, copy, then start it again:

ssh shawn@bmpc-kent-scale sudo systemctl stop device-reporter
scp device-reporter shawn@bmpc-kent-scale:device-reporter
ssh shawn@bmpc-kent-scale sudo systemctl start device-reporter

Running it

cargo run --release # serve on 127.0.0.1:8080, auto-detect devices
cargo run --release -- --demo # no hardware: a simulated scale weighs a visitor every 15 s
cargo run --release -- list # serial ports with VID:PID, serial number, product
cargo run --release -- sniff COM5 # hex + text dump of whatever COM5 sends

Open http://localhost:8080/ and step on the scale.

Every flag has an environment variable (DR_*); run device-reporter --help. The ones you will actually use:

FlagEnvDefaultPurpose
--bindDR_BIND127.0.0.1:8080Listen address. Use 0.0.0.0:8080 to reach it from another machine without nginx.
--assign PORT=DRIVERDR_ASSIGNForce a driver onto a port, e.g. /dev/scale=healthometer_scale.
--fallback-driverDR_FALLBACK_DRIVERDriver for /dev/ttyUSB* or /dev/ttyACM* ports that expose no USB descriptors. Normally unnecessary; the CP210x is recognised by VID:PID.
--cors-originDR_CORS_ORIGINBrowser origins allowed to call the API cross-origin; needed for the WASM build of the EMR client.
--hostDR_HOSThostnameName reported as host and used in device IDs.
--scale-quiet-msDR_SCALE_QUIET_MS2500Silence that ends a weigh-in.
--scale-min-weight-kgDR_SCALE_MIN_WEIGHT_KG1Below this the result is flagged below_minimum.

On Windows the scale needs the Silicon Labs CP210x driver. Linux has it built in.

API

RouteReturns
GET /The status page.
GET /api/statusProcess info plus every device seen since start.
GET /api/devicesJust the device list.
GET /api/latest[?device=ID]Most recent observation, optionally from one device. 404 until there is one.
GET /api/observations[?device=ID&limit=N]Recent observations, newest first.
GET /wsLive event stream (below).

Events

Every WebSocket message is JSON with v (wire version, currently 1) and type. On connect you get a server snapshot and the latest observation from each device, then live events.

// server: sent once on connect
{"v":1,"type":"server","host":"scale-pi","version":"0.2.0","started_at":"","devices":[]}
// device: a device connected, disconnected, or started/stopped measuring
{"v":1,"type":"device","id":"scale-pi-dev-ttyUSB0","kind":"healthometer_scale",
"display_name":"Health o meter scale","port":"/dev/ttyUSB0","connected":true,
"last_error":null,"last_data_at":"","active":true}
// reading: provisional, once per second while someone is on the scale
{"v":1,"type":"reading","device_id":"","device_kind":"healthometer_scale","at":"",
"subject_hint":null,"components":[{"code":"29463-7","display":"Body weight","value":184.5,"unit":"[lb_av]"}]}
// observation: one completed result
{"v":1,"type":"observation","id":"d094ff0f-…","device_id":"","device_kind":"healthometer_scale",
"captured_at":"","completed_at":"","subject_hint":null,
"components":[
{"code":"29463-7","display":"Body weight","value":72.4,"unit":"kg"},
{"code":"8302-2","display":"Body height","value":178.0,"unit":"cm"},
{"code":"39156-5","display":"Body mass index","value":22.9,"unit":"kg/m2"}],
"flags":[],"packets":5}

components[].code is LOINC; unit is UCUM (kg, [lb_av], cm, [in_i], kg/m2, later mm[Hg], g/dL, …). value is a number or, for coded results such as a urinalysis "trace", a string. subject_hint is whatever ID the device itself carried (the scale keypad); it is a hint for the clinician, never an identity. id is random per observation so the EMR can accept or discard exactly one.

How the scale driver works

The protocol (see reference/HealthometerProf.CommunicationProtocols*.pdf) is 9600 8N1 and each packet looks like this, with no newline:

<ESC>R<ESC>I1234567890<ESC>W184.5<ESC>H84.0<ESC>B24.1<ESC>T0.0<ESC>Nm<ESC>E

src/drivers/healthometer/protocol.rs frames on the <ESC>E terminator, anchors on the last R field so a fragment from connecting mid-stream can never be glued onto the next packet and misread as its weight, and refuses packets with no weight or no units. session.rs turns the once-per-second stream into one result. Both are pure and unit-tested against the packets printed in the PDF.

Adding a device

  1. Find it: device-reporter list shows VID:PID and product strings.
  2. Learn its protocol: device-reporter sniff COM7 --baud 9600 (and --parity, --data-bits, --send-hex "1b 52" for devices that need a request). --capture file.bin saves the raw bytes for a unit test.
  3. Create src/drivers/<device>/mod.rs implementing Driver (how to recognise the port and its line settings) and DeviceSession (bytes in, Output::Live / Output::Complete out). Look at the healthometer driver.
  4. Register it in driver::registry.

Devices queued up: Welch Allyn Spot Vital Signs LXi, Detecto sonar stadiometer, hemoglobin meter. Findings so far, including which port each device actually talks on, are in docs/devices.md.

Project layout

├── Cargo.toml
├── src/
│ ├── main.rs CLI (serve / list / sniff), wiring
│ ├── model.rs Observation, Component, DeviceStatus, Event: the JSON contract
│ ├── driver.rs Driver and DeviceSession traits, port matching, registry
│ ├── drivers/
│ │ ├── healthometer/ protocol.rs (framing, parsing), session.rs (coalescing), mod.rs (driver)
│ │ └── consult120.rs urine analyzer: STX/ETX text report to LOINC components
│ ├── manager.rs port scanning, driver pairing, hot-plug, one thread per device
│ ├── serial.rs the blocking connection loop shared by every serial driver
│ ├── state.rs shared state and the broadcast channel
│ ├── web.rs axum routes and the WebSocket
│ ├── sniff.rs `list` and `sniff`
│ └── demo.rs a fake scale for `--demo`
├── static/index.html the status page, embedded in the binary
├── docs/devices.md per-device notes: what is known, what was tried, what is next
└── reference/ the manufacturer protocol PDF, wiring photos, screenshots

cargo test runs 45 unit tests, none of which need hardware. cargo clippy --all-targets is clean under the strict lint set in Cargo.toml (no unwrap, expect, panic or indexing in non-test code).


Raspberry Pi deployment

Background: I am a Family Physician and love tinkering, programming, teaching and learning. I like when my hobbies can intersect with my profession as well. This is the permanently installed Raspberry Pi that lives behind the scale, documented so I can rebuild it if it is ever lost, and so anyone else can try it.

Audience: someone who knows what a terminal is but may not be overly familiar with Linux.

Consider trying the project from your laptop first (cargo run --release -- --bind 0.0.0.0:8080) to see that your scale is compatible before buying anything.

Parts list

  • Raspberry Pi Zero W (32-bit, ARMv6), which I use, or a Raspberry Pi Zero 2 W (~$15). Any Pi works; this is a stupidly low load, and the Zero draws only about 0.6-1.2 W.
  • Case for the Pi. I 3D printed one.
  • Micro-USB cable for power, or a USB power-only cable with switch.
  • MicroSD card, at least 1 GB.
  • Micro-USB to USB-B cable. Hard to find; I bought these on Amazon. A USB Mini-to-A adapter also works, or use a full-size Pi.
  • A scale with the connectivity option. Hopefully you have this already if you are reading this.

Scale label

Image the Pi (headless)

Generate an SSH key pair

Set up an SSH key before imaging so password logins can be disabled. On Windows I use PuTTY; PuTTYgen, included in the download, generates the key. Save it somewhere safe; you will paste the public key into the imager next.

PuTTYgen

Write Raspberry Pi OS

Install Raspberry Pi OS (Lite is fine) with Raspberry Pi Imager. In the settings, choose a hostname (I use scale-pi), enter the Wi-Fi password, enable SSH, and paste the public key.

Raspberry Pi Imager

Save the PuTTY session

  • Host name: scale-pi.local
  • Connection → SSH → Auth → Credentials: your .ppk file
  • Connection → Data: your username
  • Session: give it a name and Save

First boot and login

Pop the card in, power up, wait a minute, open the saved PuTTY session, accept the host key.

PuTTY alert

Hint: right-clicking the terminal pastes.

Update everything, then reboot:

sudo apt update && sudo apt upgrade -y
sudo raspi-config # expand filesystem, set logging to volatile
sudo shutdown -r now

Build the binary for the Pi (Or skip this section and download the release)

Cross-compile on your PC; the Pi Zero would take an age to compile Rust. From WSL (Ubuntu), with Rust, zig and cargo install cargo-zigbuild, once:

cd /mnt/c/Users/<you>/PycharmProject/ScaleReporter
rustup target add arm-unknown-linux-gnueabihf # 32-bit: Pi Zero, Zero W, Pi 1 (ARMv6)
rustup target add aarch64-unknown-linux-gnu # 64-bit: Zero 2 W, Pi 3/4/5 on 64-bit Raspberry Pi OS

Then, per release:

# Pi Zero / Zero W (32-bit)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target arm-unknown-linux-gnueabihf.2.31
# -> target/pi/arm-unknown-linux-gnueabihf/release/device-reporter# Pi Zero 2 W, Pi 3/4/5 (64-bit OS)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.31
# -> target/pi/aarch64-unknown-linux-gnu/release/device-reporter

Pick by the OS you imaged, not only the board: a Zero 2 W running the 32-bit Raspberry Pi OS needs the 32-bit binary. uname -m on the Pi says armv6l/armv7l for 32-bit and aarch64 for 64-bit. The .2.31 pins glibc 2.31 (Raspberry Pi OS Bullseye), which also runs on Bookworm (2.36) and newer. Each binary is about 2 MB with no dependencies beyond glibc. CARGO_TARGET_DIR=target/pi keeps the WSL build out of the Windows target/ directory.

Copy it over with scp as shown in Transferring it to the Raspberry Pi above.

Plug in and test

Plug the scale into the Pi (the Zero's inner micro-USB port is data; the outer one is power only) and run the binary:

~/device-reporter --bind 0.0.0.0:8080

Within a few seconds the log shows opening device ... port=/dev/ttyUSB0 driver=healthometer_scale. The CP210x bridge is recognized by its USB vendor/product ID, so no port name or driver flag is needed, and the default Raspberry Pi OS user is already in the dialout group. If a port ever shows up as "no matching driver", ~/device-reporter list shows what the OS knows about it and --assign /dev/ttyUSB0=healthometer_scale forces the pairing.

Browse to http://.local:8080/, step on the scale, Ctrl-C when happy.

Run it as a service

sudo nano /etc/systemd/system/device-reporter.service
[Unit]
Description=Device Reporter (clinic USB devices)
After=network.target
[Service]
ExecStart=/home/shawn/device-reporter
WorkingDirectory=/home/shawn
Environment=DR_BIND=127.0.0.1:8080
Environment=RUST_LOG=info
Restart=always
RestartSec=3
User=shawn
Group=dialout
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now device-reporter.service
journalctl -u device-reporter.service -f # follow the logs

To upgrade: sudo systemctl stop device-reporter, scp the new binary over, then sudo systemctl start device-reporter. Copying while the service runs fails with "text file busy" because Linux will not overwrite an executable that is in use.

DR_BIND=127.0.0.1 means only nginx on the Pi can reach it, which is what you want once the next section is done. For testing without nginx, use 0.0.0.0:8080.

Optional basic security improvements

Tailscale

I installed Tailscale because it just works. It encrypts everything with WireGuard and gives easy access control: any device on the tailnet can reach the Pi from anywhere; nothing else can. The Tailscale admin page generates the install command:

curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up --auth-key=tskey-auth-.....
sudo tailscale set --auto-update

nginx

Forward :80 to the service so no port is needed in the URL, and so TLS can be added later.

sudo apt install nginx -y
sudo rm /etc/nginx/sites-enabled/default
sudo nano /etc/nginx/sites-available/device-reporter
server{listen80;server_name _;location / {proxy_passhttp://127.0.0.1:8080;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme; # WebSocketproxy_http_version 1.1;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade"; # The service pings every 20 s; this is just belt and braces for quiet nights.proxy_read_timeout1h;}}
sudo ln -s /etc/nginx/sites-available/device-reporter /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Now http://scale-pi/ works from any tailnet device.

UFW firewall

Admit SSH and HTTP only over Tailscale:

sudo apt install ufw -y
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0 to any port 22
sudo ufw allow in on tailscale0 to any port 80
sudo ufw allow in on tailscale0 to any port 443
sudo ufw allow 41641/udp # helps Tailscale connect peer-to-peer
sudo ufw status numbered
sudo ufw enable
sudo service ssh restart

Ultimate test: the page and SSH work with Tailscale running on your PC and fail with it quit. That is a zero-trust setup: only authorized devices can reach the Pi at all.


Next: into the chart

The plan is for the EMR client (front_desk, an egui app that already speaks WebSocket) to subscribe to /ws, and when an observation arrives while a chart is open, show it as a pending vital: "Weight 184.5 lb from the Room 2 scale, 12 s ago. Accept into this chart?" The clinician confirms it is the right person (not their kid playing on the scale) and the client writes a FHIR Observation with device provenance. Nothing is charted automatically.

Longer term the Pi should post observations to the FHIR server instead, which gives a device registry, room-to-device mapping, an audit trail, and a pending queue that survives client restarts.

Contributing

Contributions are welcome. Add a driver for your device; a raw capture from sniff --capture makes a great unit test fixture.

License

MIT. You are free to use, modify, and distribute this software, provided that proper attribution is given.

Acknowledgments

  • Health o meter (Pelstar) for hardware that has worked for 10+ years and for publishing their serial protocol.
  • The serialport, axum, tokio and jiff crates.

About

This project interfaces with an electronic scale via USB and provides real-time weight updates through a web interface and an API.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Device Reporter (formerly ScaleReporter)

A small Rust service that reads clinic devices over USB serial and publishes their results over HTTP and WebSocket, so a weight (and soon a blood pressure, a height, a urinalysis, a hemoglobin) can flow into the chart without anyone retyping it.

It started life as a Python script for one scale. It is now a generic reporter with a driver per device. Drivers so far: the Health o meter large-platform scale (1100 / 2000 series, "L" and "E" serial versions) over its CP210x USB-to-UART option and the McKesson Consult 120 urine analyzer over its USB serial port. Adding a device means writing one driver module; everything else (detection, hot-plug, the API, the page) is shared.


Current setup at my office:

current-setup-scale.jpg

current-setup-scale-2.jpg

Navigating to the Web UI

Scale Web UI

device-reporter-scale.png

Urinalysis UI

device-reporter-urinalysis.png

What it does

  • Auto-detects devices. Serial ports are scanned every few seconds and matched to drivers by USB vendor/product ID. Unplug and replug at will.
  • One event per result, not one per packet. The scale streams the same locked weight every second while someone stands on it. The driver coalesces that into a single observation when they step off. A different weight (a child hopping on after a parent) starts a new one.
  • Plausibility flags for the clinician.below_minimum for a bag or a foot on the platform, single_packet when the scale only re-displayed a previous weight (RECALL/UNITS button).
  • FHIR-ready output. Every result carries LOINC codes and UCUM units, so the EMR can build Observation resources without device-specific knowledge.
  • Live page, REST API, and a WebSocket stream with reconnection and keep-alive pings.
  • list and sniff subcommands for reverse-engineering the next device's protocol.
  • Single static binary, cross-compiled for the Raspberry Pi Zero W. No Python, no venv, no driver install on Linux.

HIPAA / Security

This is a work in progress and does not by itself protect health information. It serves plain HTTP on the address you bind it to, with no authentication. Results contain a weight, timestamps, and whatever ID was typed on the device keypad.

Mitigations used here: bind to loopback and put nginx in front; run the Pi on Tailscale and let UFW admit only the tailnet (see below); keep logs at INFO, which never print device-entered IDs. Extreme care must be used in order to avoid HIPAA violations. You have been warned; this project and its contributors accept no responsibility for how it is deployed.


Transferring it to the Raspberry Pi:

cd PycharmProject/ScaleReporter/target/pi/arm-unknown-linux-gnueabihf/release
scp device-reporter shawn@bmpc-kent-scale:device-reporter

Caveat when updating: Linux will not overwrite a binary that is running (scp: dest open "device-reporter": Failure, "text file busy"). Stop the service first, copy, then start it again:

ssh shawn@bmpc-kent-scale sudo systemctl stop device-reporter
scp device-reporter shawn@bmpc-kent-scale:device-reporter
ssh shawn@bmpc-kent-scale sudo systemctl start device-reporter

Running it

cargo run --release # serve on 127.0.0.1:8080, auto-detect devices
cargo run --release -- --demo # no hardware: a simulated scale weighs a visitor every 15 s
cargo run --release -- list # serial ports with VID:PID, serial number, product
cargo run --release -- sniff COM5 # hex + text dump of whatever COM5 sends

Open http://localhost:8080/ and step on the scale.

Every flag has an environment variable (DR_*); run device-reporter --help. The ones you will actually use:

FlagEnvDefaultPurpose
--bindDR_BIND127.0.0.1:8080Listen address. Use 0.0.0.0:8080 to reach it from another machine without nginx.
--assign PORT=DRIVERDR_ASSIGNForce a driver onto a port, e.g. /dev/scale=healthometer_scale.
--fallback-driverDR_FALLBACK_DRIVERDriver for /dev/ttyUSB* or /dev/ttyACM* ports that expose no USB descriptors. Normally unnecessary; the CP210x is recognised by VID:PID.
--cors-originDR_CORS_ORIGINBrowser origins allowed to call the API cross-origin; needed for the WASM build of the EMR client.
--hostDR_HOSThostnameName reported as host and used in device IDs.
--scale-quiet-msDR_SCALE_QUIET_MS2500Silence that ends a weigh-in.
--scale-min-weight-kgDR_SCALE_MIN_WEIGHT_KG1Below this the result is flagged below_minimum.

On Windows the scale needs the Silicon Labs CP210x driver. Linux has it built in.

API

RouteReturns
GET /The status page.
GET /api/statusProcess info plus every device seen since start.
GET /api/devicesJust the device list.
GET /api/latest[?device=ID]Most recent observation, optionally from one device. 404 until there is one.
GET /api/observations[?device=ID&limit=N]Recent observations, newest first.
GET /wsLive event stream (below).

Events

Every WebSocket message is JSON with v (wire version, currently 1) and type. On connect you get a server snapshot and the latest observation from each device, then live events.

// server: sent once on connect
{"v":1,"type":"server","host":"scale-pi","version":"0.2.0","started_at":"","devices":[]}
// device: a device connected, disconnected, or started/stopped measuring
{"v":1,"type":"device","id":"scale-pi-dev-ttyUSB0","kind":"healthometer_scale",
"display_name":"Health o meter scale","port":"/dev/ttyUSB0","connected":true,
"last_error":null,"last_data_at":"","active":true}
// reading: provisional, once per second while someone is on the scale
{"v":1,"type":"reading","device_id":"","device_kind":"healthometer_scale","at":"",
"subject_hint":null,"components":[{"code":"29463-7","display":"Body weight","value":184.5,"unit":"[lb_av]"}]}
// observation: one completed result
{"v":1,"type":"observation","id":"d094ff0f-…","device_id":"","device_kind":"healthometer_scale",
"captured_at":"","completed_at":"","subject_hint":null,
"components":[
{"code":"29463-7","display":"Body weight","value":72.4,"unit":"kg"},
{"code":"8302-2","display":"Body height","value":178.0,"unit":"cm"},
{"code":"39156-5","display":"Body mass index","value":22.9,"unit":"kg/m2"}],
"flags":[],"packets":5}

components[].code is LOINC; unit is UCUM (kg, [lb_av], cm, [in_i], kg/m2, later mm[Hg], g/dL, …). value is a number or, for coded results such as a urinalysis "trace", a string. subject_hint is whatever ID the device itself carried (the scale keypad); it is a hint for the clinician, never an identity. id is random per observation so the EMR can accept or discard exactly one.

How the scale driver works

The protocol (see reference/HealthometerProf.CommunicationProtocols*.pdf) is 9600 8N1 and each packet looks like this, with no newline:

<ESC>R<ESC>I1234567890<ESC>W184.5<ESC>H84.0<ESC>B24.1<ESC>T0.0<ESC>Nm<ESC>E

src/drivers/healthometer/protocol.rs frames on the <ESC>E terminator, anchors on the last R field so a fragment from connecting mid-stream can never be glued onto the next packet and misread as its weight, and refuses packets with no weight or no units. session.rs turns the once-per-second stream into one result. Both are pure and unit-tested against the packets printed in the PDF.

Adding a device

  1. Find it: device-reporter list shows VID:PID and product strings.
  2. Learn its protocol: device-reporter sniff COM7 --baud 9600 (and --parity, --data-bits, --send-hex "1b 52" for devices that need a request). --capture file.bin saves the raw bytes for a unit test.
  3. Create src/drivers/<device>/mod.rs implementing Driver (how to recognise the port and its line settings) and DeviceSession (bytes in, Output::Live / Output::Complete out). Look at the healthometer driver.
  4. Register it in driver::registry.

Devices queued up: Welch Allyn Spot Vital Signs LXi, Detecto sonar stadiometer, hemoglobin meter. Findings so far, including which port each device actually talks on, are in docs/devices.md.

Project layout

├── Cargo.toml
├── src/
│ ├── main.rs CLI (serve / list / sniff), wiring
│ ├── model.rs Observation, Component, DeviceStatus, Event: the JSON contract
│ ├── driver.rs Driver and DeviceSession traits, port matching, registry
│ ├── drivers/
│ │ ├── healthometer/ protocol.rs (framing, parsing), session.rs (coalescing), mod.rs (driver)
│ │ └── consult120.rs urine analyzer: STX/ETX text report to LOINC components
│ ├── manager.rs port scanning, driver pairing, hot-plug, one thread per device
│ ├── serial.rs the blocking connection loop shared by every serial driver
│ ├── state.rs shared state and the broadcast channel
│ ├── web.rs axum routes and the WebSocket
│ ├── sniff.rs `list` and `sniff`
│ └── demo.rs a fake scale for `--demo`
├── static/index.html the status page, embedded in the binary
├── docs/devices.md per-device notes: what is known, what was tried, what is next
└── reference/ the manufacturer protocol PDF, wiring photos, screenshots

cargo test runs 45 unit tests, none of which need hardware. cargo clippy --all-targets is clean under the strict lint set in Cargo.toml (no unwrap, expect, panic or indexing in non-test code).


Raspberry Pi deployment

Background: I am a Family Physician and love tinkering, programming, teaching and learning. I like when my hobbies can intersect with my profession as well. This is the permanently installed Raspberry Pi that lives behind the scale, documented so I can rebuild it if it is ever lost, and so anyone else can try it.

Audience: someone who knows what a terminal is but may not be overly familiar with Linux.

Consider trying the project from your laptop first (cargo run --release -- --bind 0.0.0.0:8080) to see that your scale is compatible before buying anything.

Parts list

  • Raspberry Pi Zero W (32-bit, ARMv6), which I use, or a Raspberry Pi Zero 2 W (~$15). Any Pi works; this is a stupidly low load, and the Zero draws only about 0.6-1.2 W.
  • Case for the Pi. I 3D printed one.
  • Micro-USB cable for power, or a USB power-only cable with switch.
  • MicroSD card, at least 1 GB.
  • Micro-USB to USB-B cable. Hard to find; I bought these on Amazon. A USB Mini-to-A adapter also works, or use a full-size Pi.
  • A scale with the connectivity option. Hopefully you have this already if you are reading this.

Scale label

Image the Pi (headless)

Generate an SSH key pair

Set up an SSH key before imaging so password logins can be disabled. On Windows I use PuTTY; PuTTYgen, included in the download, generates the key. Save it somewhere safe; you will paste the public key into the imager next.

PuTTYgen

Write Raspberry Pi OS

Install Raspberry Pi OS (Lite is fine) with Raspberry Pi Imager. In the settings, choose a hostname (I use scale-pi), enter the Wi-Fi password, enable SSH, and paste the public key.

Raspberry Pi Imager

Save the PuTTY session

  • Host name: scale-pi.local
  • Connection → SSH → Auth → Credentials: your .ppk file
  • Connection → Data: your username
  • Session: give it a name and Save

First boot and login

Pop the card in, power up, wait a minute, open the saved PuTTY session, accept the host key.

PuTTY alert

Hint: right-clicking the terminal pastes.

Update everything, then reboot:

sudo apt update && sudo apt upgrade -y
sudo raspi-config # expand filesystem, set logging to volatile
sudo shutdown -r now

Build the binary for the Pi (Or skip this section and download the release)

Cross-compile on your PC; the Pi Zero would take an age to compile Rust. From WSL (Ubuntu), with Rust, zig and cargo install cargo-zigbuild, once:

cd /mnt/c/Users/<you>/PycharmProject/ScaleReporter
rustup target add arm-unknown-linux-gnueabihf # 32-bit: Pi Zero, Zero W, Pi 1 (ARMv6)
rustup target add aarch64-unknown-linux-gnu # 64-bit: Zero 2 W, Pi 3/4/5 on 64-bit Raspberry Pi OS

Then, per release:

# Pi Zero / Zero W (32-bit)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target arm-unknown-linux-gnueabihf.2.31
# -> target/pi/arm-unknown-linux-gnueabihf/release/device-reporter# Pi Zero 2 W, Pi 3/4/5 (64-bit OS)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.31
# -> target/pi/aarch64-unknown-linux-gnu/release/device-reporter

Pick by the OS you imaged, not only the board: a Zero 2 W running the 32-bit Raspberry Pi OS needs the 32-bit binary. uname -m on the Pi says armv6l/armv7l for 32-bit and aarch64 for 64-bit. The .2.31 pins glibc 2.31 (Raspberry Pi OS Bullseye), which also runs on Bookworm (2.36) and newer. Each binary is about 2 MB with no dependencies beyond glibc. CARGO_TARGET_DIR=target/pi keeps the WSL build out of the Windows target/ directory.

Copy it over with scp as shown in Transferring it to the Raspberry Pi above.

Plug in and test

Plug the scale into the Pi (the Zero's inner micro-USB port is data; the outer one is power only) and run the binary:

~/device-reporter --bind 0.0.0.0:8080

Within a few seconds the log shows opening device ... port=/dev/ttyUSB0 driver=healthometer_scale. The CP210x bridge is recognized by its USB vendor/product ID, so no port name or driver flag is needed, and the default Raspberry Pi OS user is already in the dialout group. If a port ever shows up as "no matching driver", ~/device-reporter list shows what the OS knows about it and --assign /dev/ttyUSB0=healthometer_scale forces the pairing.

Browse to http://.local:8080/, step on the scale, Ctrl-C when happy.

Run it as a service

sudo nano /etc/systemd/system/device-reporter.service
[Unit]
Description=Device Reporter (clinic USB devices)
After=network.target
[Service]
ExecStart=/home/shawn/device-reporter
WorkingDirectory=/home/shawn
Environment=DR_BIND=127.0.0.1:8080
Environment=RUST_LOG=info
Restart=always
RestartSec=3
User=shawn
Group=dialout
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now device-reporter.service
journalctl -u device-reporter.service -f # follow the logs

To upgrade: sudo systemctl stop device-reporter, scp the new binary over, then sudo systemctl start device-reporter. Copying while the service runs fails with "text file busy" because Linux will not overwrite an executable that is in use.

DR_BIND=127.0.0.1 means only nginx on the Pi can reach it, which is what you want once the next section is done. For testing without nginx, use 0.0.0.0:8080.

Optional basic security improvements

Tailscale

I installed Tailscale because it just works. It encrypts everything with WireGuard and gives easy access control: any device on the tailnet can reach the Pi from anywhere; nothing else can. The Tailscale admin page generates the install command:

curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up --auth-key=tskey-auth-.....
sudo tailscale set --auto-update

nginx

Forward :80 to the service so no port is needed in the URL, and so TLS can be added later.

sudo apt install nginx -y
sudo rm /etc/nginx/sites-enabled/default
sudo nano /etc/nginx/sites-available/device-reporter
server{listen80;server_name _;location / {proxy_passhttp://127.0.0.1:8080;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme; # WebSocketproxy_http_version 1.1;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade"; # The service pings every 20 s; this is just belt and braces for quiet nights.proxy_read_timeout1h;}}
sudo ln -s /etc/nginx/sites-available/device-reporter /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Now http://scale-pi/ works from any tailnet device.

UFW firewall

Admit SSH and HTTP only over Tailscale:

sudo apt install ufw -y
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0 to any port 22
sudo ufw allow in on tailscale0 to any port 80
sudo ufw allow in on tailscale0 to any port 443
sudo ufw allow 41641/udp # helps Tailscale connect peer-to-peer
sudo ufw status numbered
sudo ufw enable
sudo service ssh restart

Ultimate test: the page and SSH work with Tailscale running on your PC and fail with it quit. That is a zero-trust setup: only authorized devices can reach the Pi at all.


Next: into the chart

The plan is for the EMR client (front_desk, an egui app that already speaks WebSocket) to subscribe to /ws, and when an observation arrives while a chart is open, show it as a pending vital: "Weight 184.5 lb from the Room 2 scale, 12 s ago. Accept into this chart?" The clinician confirms it is the right person (not their kid playing on the scale) and the client writes a FHIR Observation with device provenance. Nothing is charted automatically.

Longer term the Pi should post observations to the FHIR server instead, which gives a device registry, room-to-device mapping, an audit trail, and a pending queue that survives client restarts.

Contributing

Contributions are welcome. Add a driver for your device; a raw capture from sniff --capture makes a great unit test fixture.

License

MIT. You are free to use, modify, and distribute this software, provided that proper attribution is given.

Acknowledgments

  • Health o meter (Pelstar) for hardware that has worked for 10+ years and for publishing their serial protocol.
  • The serialport, axum, tokio and jiff crates.

About

This project interfaces with an electronic scale via USB and provides real-time weight updates through a web interface and an API.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Latest commit

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Device Reporter (formerly ScaleReporter)

A small Rust service that reads clinic devices over USB serial and publishes their results over HTTP and WebSocket, so a weight (and soon a blood pressure, a height, a urinalysis, a hemoglobin) can flow into the chart without anyone retyping it.

It started life as a Python script for one scale. It is now a generic reporter with a driver per device. Drivers so far: the Health o meter large-platform scale (1100 / 2000 series, "L" and "E" serial versions) over its CP210x USB-to-UART option and the McKesson Consult 120 urine analyzer over its USB serial port. Adding a device means writing one driver module; everything else (detection, hot-plug, the API, the page) is shared.


Current setup at my office:

current-setup-scale.jpg

current-setup-scale-2.jpg

Navigating to the Web UI

Scale Web UI

device-reporter-scale.png

Urinalysis UI

device-reporter-urinalysis.png

What it does

  • Auto-detects devices. Serial ports are scanned every few seconds and matched to drivers by USB vendor/product ID. Unplug and replug at will.
  • One event per result, not one per packet. The scale streams the same locked weight every second while someone stands on it. The driver coalesces that into a single observation when they step off. A different weight (a child hopping on after a parent) starts a new one.
  • Plausibility flags for the clinician.below_minimum for a bag or a foot on the platform, single_packet when the scale only re-displayed a previous weight (RECALL/UNITS button).
  • FHIR-ready output. Every result carries LOINC codes and UCUM units, so the EMR can build Observation resources without device-specific knowledge.
  • Live page, REST API, and a WebSocket stream with reconnection and keep-alive pings.
  • list and sniff subcommands for reverse-engineering the next device's protocol.
  • Single static binary, cross-compiled for the Raspberry Pi Zero W. No Python, no venv, no driver install on Linux.

HIPAA / Security

This is a work in progress and does not by itself protect health information. It serves plain HTTP on the address you bind it to, with no authentication. Results contain a weight, timestamps, and whatever ID was typed on the device keypad.

Mitigations used here: bind to loopback and put nginx in front; run the Pi on Tailscale and let UFW admit only the tailnet (see below); keep logs at INFO, which never print device-entered IDs. Extreme care must be used in order to avoid HIPAA violations. You have been warned; this project and its contributors accept no responsibility for how it is deployed.


Transferring it to the Raspberry Pi:

cd PycharmProject/ScaleReporter/target/pi/arm-unknown-linux-gnueabihf/release
scp device-reporter shawn@bmpc-kent-scale:device-reporter

Caveat when updating: Linux will not overwrite a binary that is running (scp: dest open "device-reporter": Failure, "text file busy"). Stop the service first, copy, then start it again:

ssh shawn@bmpc-kent-scale sudo systemctl stop device-reporter
scp device-reporter shawn@bmpc-kent-scale:device-reporter
ssh shawn@bmpc-kent-scale sudo systemctl start device-reporter

Running it

cargo run --release # serve on 127.0.0.1:8080, auto-detect devices
cargo run --release -- --demo # no hardware: a simulated scale weighs a visitor every 15 s
cargo run --release -- list # serial ports with VID:PID, serial number, product
cargo run --release -- sniff COM5 # hex + text dump of whatever COM5 sends

Open http://localhost:8080/ and step on the scale.

Every flag has an environment variable (DR_*); run device-reporter --help. The ones you will actually use:

FlagEnvDefaultPurpose
--bindDR_BIND127.0.0.1:8080Listen address. Use 0.0.0.0:8080 to reach it from another machine without nginx.
--assign PORT=DRIVERDR_ASSIGNForce a driver onto a port, e.g. /dev/scale=healthometer_scale.
--fallback-driverDR_FALLBACK_DRIVERDriver for /dev/ttyUSB* or /dev/ttyACM* ports that expose no USB descriptors. Normally unnecessary; the CP210x is recognised by VID:PID.
--cors-originDR_CORS_ORIGINBrowser origins allowed to call the API cross-origin; needed for the WASM build of the EMR client.
--hostDR_HOSThostnameName reported as host and used in device IDs.
--scale-quiet-msDR_SCALE_QUIET_MS2500Silence that ends a weigh-in.
--scale-min-weight-kgDR_SCALE_MIN_WEIGHT_KG1Below this the result is flagged below_minimum.

On Windows the scale needs the Silicon Labs CP210x driver. Linux has it built in.

API

RouteReturns
GET /The status page.
GET /api/statusProcess info plus every device seen since start.
GET /api/devicesJust the device list.
GET /api/latest[?device=ID]Most recent observation, optionally from one device. 404 until there is one.
GET /api/observations[?device=ID&limit=N]Recent observations, newest first.
GET /wsLive event stream (below).

Events

Every WebSocket message is JSON with v (wire version, currently 1) and type. On connect you get a server snapshot and the latest observation from each device, then live events.

// server: sent once on connect
{"v":1,"type":"server","host":"scale-pi","version":"0.2.0","started_at":"","devices":[]}
// device: a device connected, disconnected, or started/stopped measuring
{"v":1,"type":"device","id":"scale-pi-dev-ttyUSB0","kind":"healthometer_scale",
"display_name":"Health o meter scale","port":"/dev/ttyUSB0","connected":true,
"last_error":null,"last_data_at":"","active":true}
// reading: provisional, once per second while someone is on the scale
{"v":1,"type":"reading","device_id":"","device_kind":"healthometer_scale","at":"",
"subject_hint":null,"components":[{"code":"29463-7","display":"Body weight","value":184.5,"unit":"[lb_av]"}]}
// observation: one completed result
{"v":1,"type":"observation","id":"d094ff0f-…","device_id":"","device_kind":"healthometer_scale",
"captured_at":"","completed_at":"","subject_hint":null,
"components":[
{"code":"29463-7","display":"Body weight","value":72.4,"unit":"kg"},
{"code":"8302-2","display":"Body height","value":178.0,"unit":"cm"},
{"code":"39156-5","display":"Body mass index","value":22.9,"unit":"kg/m2"}],
"flags":[],"packets":5}

components[].code is LOINC; unit is UCUM (kg, [lb_av], cm, [in_i], kg/m2, later mm[Hg], g/dL, …). value is a number or, for coded results such as a urinalysis "trace", a string. subject_hint is whatever ID the device itself carried (the scale keypad); it is a hint for the clinician, never an identity. id is random per observation so the EMR can accept or discard exactly one.

How the scale driver works

The protocol (see reference/HealthometerProf.CommunicationProtocols*.pdf) is 9600 8N1 and each packet looks like this, with no newline:

<ESC>R<ESC>I1234567890<ESC>W184.5<ESC>H84.0<ESC>B24.1<ESC>T0.0<ESC>Nm<ESC>E

src/drivers/healthometer/protocol.rs frames on the <ESC>E terminator, anchors on the last R field so a fragment from connecting mid-stream can never be glued onto the next packet and misread as its weight, and refuses packets with no weight or no units. session.rs turns the once-per-second stream into one result. Both are pure and unit-tested against the packets printed in the PDF.

Adding a device

  1. Find it: device-reporter list shows VID:PID and product strings.
  2. Learn its protocol: device-reporter sniff COM7 --baud 9600 (and --parity, --data-bits, --send-hex "1b 52" for devices that need a request). --capture file.bin saves the raw bytes for a unit test.
  3. Create src/drivers/<device>/mod.rs implementing Driver (how to recognise the port and its line settings) and DeviceSession (bytes in, Output::Live / Output::Complete out). Look at the healthometer driver.
  4. Register it in driver::registry.

Devices queued up: Welch Allyn Spot Vital Signs LXi, Detecto sonar stadiometer, hemoglobin meter. Findings so far, including which port each device actually talks on, are in docs/devices.md.

Project layout

├── Cargo.toml
├── src/
│ ├── main.rs CLI (serve / list / sniff), wiring
│ ├── model.rs Observation, Component, DeviceStatus, Event: the JSON contract
│ ├── driver.rs Driver and DeviceSession traits, port matching, registry
│ ├── drivers/
│ │ ├── healthometer/ protocol.rs (framing, parsing), session.rs (coalescing), mod.rs (driver)
│ │ └── consult120.rs urine analyzer: STX/ETX text report to LOINC components
│ ├── manager.rs port scanning, driver pairing, hot-plug, one thread per device
│ ├── serial.rs the blocking connection loop shared by every serial driver
│ ├── state.rs shared state and the broadcast channel
│ ├── web.rs axum routes and the WebSocket
│ ├── sniff.rs `list` and `sniff`
│ └── demo.rs a fake scale for `--demo`
├── static/index.html the status page, embedded in the binary
├── docs/devices.md per-device notes: what is known, what was tried, what is next
└── reference/ the manufacturer protocol PDF, wiring photos, screenshots

cargo test runs 45 unit tests, none of which need hardware. cargo clippy --all-targets is clean under the strict lint set in Cargo.toml (no unwrap, expect, panic or indexing in non-test code).


Raspberry Pi deployment

Background: I am a Family Physician and love tinkering, programming, teaching and learning. I like when my hobbies can intersect with my profession as well. This is the permanently installed Raspberry Pi that lives behind the scale, documented so I can rebuild it if it is ever lost, and so anyone else can try it.

Audience: someone who knows what a terminal is but may not be overly familiar with Linux.

Consider trying the project from your laptop first (cargo run --release -- --bind 0.0.0.0:8080) to see that your scale is compatible before buying anything.

Parts list

  • Raspberry Pi Zero W (32-bit, ARMv6), which I use, or a Raspberry Pi Zero 2 W (~$15). Any Pi works; this is a stupidly low load, and the Zero draws only about 0.6-1.2 W.
  • Case for the Pi. I 3D printed one.
  • Micro-USB cable for power, or a USB power-only cable with switch.
  • MicroSD card, at least 1 GB.
  • Micro-USB to USB-B cable. Hard to find; I bought these on Amazon. A USB Mini-to-A adapter also works, or use a full-size Pi.
  • A scale with the connectivity option. Hopefully you have this already if you are reading this.

Scale label

Image the Pi (headless)

Generate an SSH key pair

Set up an SSH key before imaging so password logins can be disabled. On Windows I use PuTTY; PuTTYgen, included in the download, generates the key. Save it somewhere safe; you will paste the public key into the imager next.

PuTTYgen

Write Raspberry Pi OS

Install Raspberry Pi OS (Lite is fine) with Raspberry Pi Imager. In the settings, choose a hostname (I use scale-pi), enter the Wi-Fi password, enable SSH, and paste the public key.

Raspberry Pi Imager

Save the PuTTY session

  • Host name: scale-pi.local
  • Connection → SSH → Auth → Credentials: your .ppk file
  • Connection → Data: your username
  • Session: give it a name and Save

First boot and login

Pop the card in, power up, wait a minute, open the saved PuTTY session, accept the host key.

PuTTY alert

Hint: right-clicking the terminal pastes.

Update everything, then reboot:

sudo apt update && sudo apt upgrade -y
sudo raspi-config # expand filesystem, set logging to volatile
sudo shutdown -r now

Build the binary for the Pi (Or skip this section and download the release)

Cross-compile on your PC; the Pi Zero would take an age to compile Rust. From WSL (Ubuntu), with Rust, zig and cargo install cargo-zigbuild, once:

cd /mnt/c/Users/<you>/PycharmProject/ScaleReporter
rustup target add arm-unknown-linux-gnueabihf # 32-bit: Pi Zero, Zero W, Pi 1 (ARMv6)
rustup target add aarch64-unknown-linux-gnu # 64-bit: Zero 2 W, Pi 3/4/5 on 64-bit Raspberry Pi OS

Then, per release:

# Pi Zero / Zero W (32-bit)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target arm-unknown-linux-gnueabihf.2.31
# -> target/pi/arm-unknown-linux-gnueabihf/release/device-reporter# Pi Zero 2 W, Pi 3/4/5 (64-bit OS)
CARGO_TARGET_DIR=target/pi cargo zigbuild --release --target aarch64-unknown-linux-gnu.2.31
# -> target/pi/aarch64-unknown-linux-gnu/release/device-reporter

Pick by the OS you imaged, not only the board: a Zero 2 W running the 32-bit Raspberry Pi OS needs the 32-bit binary. uname -m on the Pi says armv6l/armv7l for 32-bit and aarch64 for 64-bit. The .2.31 pins glibc 2.31 (Raspberry Pi OS Bullseye), which also runs on Bookworm (2.36) and newer. Each binary is about 2 MB with no dependencies beyond glibc. CARGO_TARGET_DIR=target/pi keeps the WSL build out of the Windows target/ directory.

Copy it over with scp as shown in Transferring it to the Raspberry Pi above.

Plug in and test

Plug the scale into the Pi (the Zero's inner micro-USB port is data; the outer one is power only) and run the binary:

~/device-reporter --bind 0.0.0.0:8080

Within a few seconds the log shows opening device ... port=/dev/ttyUSB0 driver=healthometer_scale. The CP210x bridge is recognized by its USB vendor/product ID, so no port name or driver flag is needed, and the default Raspberry Pi OS user is already in the dialout group. If a port ever shows up as "no matching driver", ~/device-reporter list shows what the OS knows about it and --assign /dev/ttyUSB0=healthometer_scale forces the pairing.

Browse to http://.local:8080/, step on the scale, Ctrl-C when happy.

Run it as a service

sudo nano /etc/systemd/system/device-reporter.service
[Unit]
Description=Device Reporter (clinic USB devices)
After=network.target
[Service]
ExecStart=/home/shawn/device-reporter
WorkingDirectory=/home/shawn
Environment=DR_BIND=127.0.0.1:8080
Environment=RUST_LOG=info
Restart=always
RestartSec=3
User=shawn
Group=dialout
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now device-reporter.service
journalctl -u device-reporter.service -f # follow the logs

To upgrade: sudo systemctl stop device-reporter, scp the new binary over, then sudo systemctl start device-reporter. Copying while the service runs fails with "text file busy" because Linux will not overwrite an executable that is in use.

DR_BIND=127.0.0.1 means only nginx on the Pi can reach it, which is what you want once the next section is done. For testing without nginx, use 0.0.0.0:8080.

Optional basic security improvements

Tailscale

I installed Tailscale because it just works. It encrypts everything with WireGuard and gives easy access control: any device on the tailnet can reach the Pi from anywhere; nothing else can. The Tailscale admin page generates the install command:

curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up --auth-key=tskey-auth-.....
sudo tailscale set --auto-update

nginx

Forward :80 to the service so no port is needed in the URL, and so TLS can be added later.

sudo apt install nginx -y
sudo rm /etc/nginx/sites-enabled/default
sudo nano /etc/nginx/sites-available/device-reporter
server{listen80;server_name _;location / {proxy_passhttp://127.0.0.1:8080;proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme; # WebSocketproxy_http_version 1.1;proxy_set_header Upgrade $http_upgrade;proxy_set_header Connection "upgrade"; # The service pings every 20 s; this is just belt and braces for quiet nights.proxy_read_timeout1h;}}
sudo ln -s /etc/nginx/sites-available/device-reporter /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Now http://scale-pi/ works from any tailnet device.

UFW firewall

Admit SSH and HTTP only over Tailscale:

sudo apt install ufw -y
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0 to any port 22
sudo ufw allow in on tailscale0 to any port 80
sudo ufw allow in on tailscale0 to any port 443
sudo ufw allow 41641/udp # helps Tailscale connect peer-to-peer
sudo ufw status numbered
sudo ufw enable
sudo service ssh restart

Ultimate test: the page and SSH work with Tailscale running on your PC and fail with it quit. That is a zero-trust setup: only authorized devices can reach the Pi at all.


Next: into the chart

The plan is for the EMR client (front_desk, an egui app that already speaks WebSocket) to subscribe to /ws, and when an observation arrives while a chart is open, show it as a pending vital: "Weight 184.5 lb from the Room 2 scale, 12 s ago. Accept into this chart?" The clinician confirms it is the right person (not their kid playing on the scale) and the client writes a FHIR Observation with device provenance. Nothing is charted automatically.

Longer term the Pi should post observations to the FHIR server instead, which gives a device registry, room-to-device mapping, an audit trail, and a pending queue that survives client restarts.

Contributing

Contributions are welcome. Add a driver for your device; a raw capture from sniff --capture makes a great unit test fixture.

License

MIT. You are free to use, modify, and distribute this software, provided that proper attribution is given.

Acknowledgments

  • Health o meter (Pelstar) for hardware that has worked for 10+ years and for publishing their serial protocol.
  • The serialport, axum, tokio and jiff crates.

About

This project interfaces with an electronic scale via USB and provides real-time weight updates through a web interface and an API.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages