A .NET 10.0 cross-platform service that reads weight data from industrial scales via IP (SMA 8.1.2 protocol, Mettler Toledo Shared Data, or custom) or RS-232 serial (continuous-stream or on-demand indicators) and posts readings to web applications via SignalR.
- The service connects to the BasicWeigh web app via SignalR
- It polls one or more scales via TCP/IP at a configurable interval (default 750ms)
- Weight readings are parsed using the SMA 8.1.2 standard and broadcast to all web clients in real-time
- Supports automatic reconnection with exponential backoff on connection loss
- Scales are configured via the built-in Swagger API and persisted to a local SQLite database
- SMA 8.1.2 Protocol — full parsing of status, range, gross/net, motion, weight, and units
- Multi-scale support — poll multiple scales simultaneously from one service instance
- SignalR — real-time weight data broadcast to web applications
- Swagger API — REST API for configuration, diagnostics, and scale management
- SQLite persistence — scale and service settings stored locally
- Cross-platform — runs on Windows, Linux, Raspberry Pi
- Forever retry — never gives up on SignalR or scale connections
- Zero command — send zero commands to scales via SignalR
- Diagnostic endpoint — view raw SMA responses for troubleshooting
- Auto-detect — listen to a continuous-output indicator and work out its frame layout
- Stream tokens — point the parser at the weight and motion columns by hand when no brand definition fits
| Protocol | Description |
|---|---|
| SMA 8.1.2 (Generic) | Standard SMA weight request/response over TCP. Supports Weigh-Tronix ZM-301 and compatible indicators. |
| Mettler Toledo Shared Data | Mettler Toledo Shared Data Services protocol via IP. |
| Custom | Any TCP-based scale protocol with configurable request/response parsing. |
| Serial continuous stream | RS-232 indicators that stream weight frames constantly. Built-in parsers for the Rice Lake IQ plus 355 / 920i EDP-PRN format (also used by Condec UMC) and the Cardinal 225 Navigator token format; other streams can be onboarded with a weightRegex in the brand definition — no code change. |
| Serial on-demand | RS-232 indicators that answer a request command (e.g. Cardinal Gross\r). |
Scale protocol definitions can be loaded from the device-definitions repo.
Typical hookup for a Condec UMC indicator streaming continuously at 9600,8,N,1:
- Hardware: indicator's RS-232 port → USB-to-serial adapter → Pi. The
adapter shows up as
/dev/ttyUSB0(check withls /dev/ttyUSB*). - Indicator: set
STREAM=EDP(orPRN) in the SERIAL menu so it emits the continuous IQ plus 355-style frames (12000LG,- 11200LGM). - Configure the scale via Swagger (
http://<pi>:5220/swagger) or the web app's scale setup:connectionType: "Serial",serialPortName: "/dev/ttyUSB0",baudRate: 9600,dataBits: 8,parity: "None",stopBits: 1,scaleBrand: "Condec — UMC / Continuous",protocol: "Continuous". - Permissions: the service user must be in the
dialoutgroup —deploy/install.shhandles this (plusSupplementaryGroups=dialoutin the systemd unit). If you installed manually:sudo usermod -aG dialout $USERand restart the service.
Troubleshooting: raw frames are logged (rate-limited) —
sudo journalctl -u scale-reader-service -f shows frame raw='...' hex=...
for every parsed reading, plus loud warnings when the port is silent (wrong
port/baud) or frames don't parse (wrong protocol/brand).
Commissioning an indicator nobody has written a weightRegex for used to mean
guessing. Auto-Detect, on the web app's scale setup screen, opens a
temporary connection with the settings being typed in, listens for a few
seconds, and reports what the indicator actually streams: the weight
start/end columns, the motion column and character, and a separate sign
column for indicators that keep the sign in its own field. It proposes a
configuration — you review the captured frames and save.
Where no brand definition fits, set the columns by hand in the Stream
Tokens panel. They are stored per scale (frameParseMode: "Positions" plus
frameWeightStart / frameWeightEnd / frameMotionIndex / frameMotionChar
/ frameSignIndex / frameSignNegChar) and take priority over the brand
regex on every read path — serial and TCP, streaming and demand. They were
configured against frames that exact indicator sent, which is better evidence
than a shared pattern. Clearing them reverts the scale to brand parsing.
Detection is pure — no ports, no sockets, no database — so you can exercise it against frames captured any other way, with nothing plugged in:
curl -X POST http://localhost:5220/api/detect -H "Content-Type: application/json" \
-d '{"frames":[" 8980 LB G "," 8860 LB G MO "]}'It reports every brand whose regex matches, not the first: several definitions in the shared repo are loose enough to match any "number + lb" frame, so a single match is not evidence of the right model. Confidence therefore rests on the columns alone.
When a capture comes back empty the reason distinguishes the causes, because each has a different fix:
| Reported | Usually means |
|---|---|
| No data arrived at all | Wrong port, baud, data bits or parity — or the indicator is not streaming. If it only replies when polled, put its command in Request Command and detect again. |
| Bytes arrived but no complete frame | Baud/parity mismatch. A hex sample of what arrived is included. |
| Port is busy | An active scale's reader already holds it — including the scale you are editing. Set Active to No, save, then detect. |
Capture reads raw bytes and splits on CR or LF rather than assuming the terminator, since that is one of the things being discovered.
Start here: the latest release. It carries prebuilt, self-contained binaries, so the target machine needs no .NET, no SDK, no git and no build — download one file, run one command.
Download scale-reader-win-x64.zip, unzip it, and from an admin
command prompt in that folder:
INSTALL.bat https://your-web-app-urlcurl -fsSL -o srs.tar.gz https://github.com/GTMichelli-Dev/scale-reader-service/releases/latest/download/scale-reader-linux-arm64.tar.gz
mkdir -p /tmp/srs && tar -xzf srs.tar.gz -C /tmp/srs
bash /tmp/srs/install.sh https://your-web-app-urlinstall.sh notices the prebuilt app folder beside it and skips both the
.NET download and the build.
Use the web app's real address — same scheme and port you would type in a browser. A wrong URL leaves the service reconnecting forever.
Both installers are idempotent: re-run the same command to update.
Needed for architectures the release does not cover (32-bit Pi, Debian x64), or
when installing a branch. install.sh falls back to clone-and-build whenever
there is no app folder beside it.
| Target | Script | Jump to |
|---|---|---|
| Linux / Raspberry Pi | deploy/install.sh |
Quick Install (Linux / Raspberry Pi) |
| Windows | deploy/INSTALL.bat → deploy/install-windows.ps1 |
Quick Install / Update (Windows) |
Bump <Version> in ScaleReaderService.csproj, then:
git tag v1.3.1 && git push origin v1.3.1.github/workflows/release.yml builds both
targets and publishes the assets. It refuses to build if the tag disagrees with
the csproj version, or if a database file ever lands in a package.
SSH into the target machine and run one of:
git clone https://github.com/GTMichelli-Dev/scale-reader-service.git /tmp/srs
# Production: web app reachable as a public URL (port 80 or 443)
bash /tmp/srs/deploy/install.sh https://basicscale.scaledata.net
# LAN-only Pi: web app listening on port 80 on the same Pi
bash /tmp/srs/deploy/install.sh http://localhost
# Local dev: web app launched with `dotnet run` (port 5110)
bash /tmp/srs/deploy/install.sh http://localhost:5110
rm -rf /tmp/srsThe web-server URL must match the actual listen port of the web app. The
LAN-only Pi deploy binds Kestrel directly to port 80 — so the right URL is
http://localhost, not http://localhost:5110 (that one is the dev default
and only applies when the web app is launched via dotnet run). A wrong URL
puts the service into an endless "Connection refused" reconnect loop:
sudo journalctl -u scale-reader-service -n 20 --no-pager | grep -E 'Connect|refused'With options:
git clone https://github.com/GTMichelli-Dev/scale-reader-service.git /tmp/srs
bash /tmp/srs/deploy/install.sh https://basicscale.scaledata.net \
--service-id plant-1 --port 5220
rm -rf /tmp/srsFor private repos, git will prompt for credentials. You can also use a deploy key or GitHub token.
Options:
| Option | Default | Description |
|---|---|---|
--service-id <id> |
default |
Unique ID for this service instance |
--port <port> |
5220 |
Swagger API port |
--branch <branch> |
master |
Git branch to install |
--install-dir <path> |
/opt/scale-reader-service |
Install location |
The install script will:
- Detect system architecture (ARM64, ARM, x64)
- Install the .NET 10 SDK and runtime permanently (skips download on future updates)
- Clone and build the service from GitHub
- Configure the web server URL
- Set up a systemd service that starts on boot
- Preserve existing database on updates
Prerequisites: Just internet access and git. No .NET needed — the script installs everything. The .NET SDK is installed permanently so future updates skip the download.
install.sh is idempotent — re-running it on a machine that already has the service installed will:
- Stop the running service (
systemctl stop scale-reader-service). - Back up
scalereaderservice.db(your scale configs and runtime settings). - Pull the latest
masterfrom GitHub. - Rebuild the binary for the local architecture.
- Restore the database.
- Reload + start the systemd unit.
So updating to the newest release is one block of commands on the Pi:
git clone https://github.com/GTMichelli-Dev/scale-reader-service.git /tmp/srs
bash /tmp/srs/deploy/install.sh http://localhost
rm -rf /tmp/srs(Pass whatever web URL you originally used. http://localhost works when the BasicWeigh web app is on the same Pi listening on port 80; https://yourdomain for a cloud-hosted web app.)
Watch the upgrade live, optional but recommended:
# In one terminal — leave this running while you run install.sh in another
sudo journalctl -u scale-reader-service -f --no-pagerYou should see the new version banner come through:
============================================
Scale Reader Service v1.3.0
Swagger: http://0.0.0.0:5220/swagger
============================================
Confirm the new version is what's actually running:
sudo journalctl -u scale-reader-service -n 100 --no-pager | grep "Scale Reader Service v" | tail -1That line should match the <Version> in ScaleReaderService.csproj. If it shows an older version, the rebuild step was skipped (rare — usually a stale cache); force a clean and re-run:
sudo systemctl stop scale-reader-service
sudo rm -rf /opt/scale-reader-service/bin /opt/scale-reader-service/obj 2>/dev/null
bash /tmp/srs/deploy/install.sh http://localhostConfirm the service connected to the web hub (so the Scale Management page can see it):
sudo journalctl -u scale-reader-service -n 30 --no-pager | grep -E 'Connect|refused' | tail -5You want to see Connected to http://.../scaleHub with no follow-up Connection refused. If you see Connection refused, the ServerUrl in the service's settings table doesn't match the web app's actual listen port — see the next section for fixing that without re-running install.sh.
The database is preserved across reinstalls — scale configs, retained tares,
BrandsUrl,ServerUrl, andServiceIdall survive. Only the binary is replaced. To start from a clean DB, stop the service and delete/opt/scale-reader-service/scalereaderservice.dbbefore runninginstall.sh.
cd ScaleReaderService
dotnet rundeploy/install-windows.ps1 is the Windows counterpart to install.sh. A
production PC usually has neither git nor the .NET SDK, so build a
self-contained package on a machine that has the source and carry it over.
On the build machine — publish into an app folder next to the scripts:
dotnet publish -c Release -r win-x64 --self-contained true -o C:\Temp\scale-reader\app
copy deploy\install-windows.ps1 C:\Temp\scale-reader\
copy deploy\INSTALL.bat C:\Temp\scale-reader\--self-contained bundles the .NET runtime, so the target PC needs no .NET at
all — worth doing even where the right runtime happens to be present, since it
removes a dependency you would otherwise re-check on every update.
On the target PC, from an admin prompt in that folder:
INSTALL.bat https://valleyag.scaledata.netOr drive the script directly for the full option set:
powershell -ExecutionPolicy Bypass -File install-windows.ps1 -WebUrl https://valleyag.scaledata.net -ServiceId valleyag-scale1| Option | Default | Description |
|---|---|---|
-WebUrl |
(required) | Base URL of the web app. Must match its real scheme and port. |
-ServiceId |
default |
Only needs changing when a site runs more than one reader service. |
-Port |
5220 |
Local Swagger / diagnostic API port. |
-InstallDir |
C:\Services\ScaleReaderService |
Install location. |
-ResetDb |
off | Start from a clean database. Destroys the scale config, serial settings and retained tares — a timestamped backup is taken first regardless. |
The script is idempotent — re-run it to update. It will:
- Validate the arguments and find the published binaries.
- Stop the service and wait for it to actually stop (it holds its own
.exe; copying too early fails with a file lock). - Back up the database to the Desktop, timestamped.
- Copy the binaries, excluding the database and its
-wal/-shmcompanions. - Write
ServerUrland the listen port intoappsettings.json. - Create the service if missing — automatic startup, and configured to restart itself on failure (5s, 15s, then every 60s), since a weighbridge PC is rarely watched. An existing service has its path corrected and startup set to automatic.
- Start it and poll
/api/status/healthuntil it answers, failing loudly if it never does. - Apply
ServiceIdandServerUrlthrough the API, not just the config file.
Step 8 matters: appsettings.json only seeds the database while ServerUrl is
still the factory default (see the settings patch in Program.cs). On an
existing install, editing the config file alone changes nothing — the API call
is what makes the script reliable on a machine that has been running for
months. It triggers a soft reconnect, so the service picks the new URL up
without restarting.
Confirm the install path first if you are unsure of it — sc qc ScaleReaderService.
Three things bite on Windows, all avoidable:
- The service locks its own
.exe. Copying over a running service fails with a file-lock error. Stop it first, and give Windows a few seconds to release the handle before copying. - The database lives in the application folder —
AppContext.BaseDirectory, i.e.C:\Services\ScaleReaderService\scalereaderservice.db. It holds the scale configuration, serial port,ServerUrl,ServiceIdand retained tares, and is not part of the publish output. Publishing over the existing folder leaves it alone; copying the app folder to a new location and switching to that will lose it unless you bring the database across. Exclude its-waland-shmcompanions from the copy too — dropping a stale write-ahead log next to a different database risks corrupting it. - Schema changes apply themselves on start. New columns are added by the
AddColumnIfMissingcalls inProgram.cs(this project usesEnsureCreatedplus hand-written column adds, not EF migrations), so an older database upgrades in place with its rows intact. No manual migration step.
Verify afterwards:
sc query ScaleReaderService
curl http://localhost:5220/api/status/healthAll configuration is done via the Swagger API at http://<your-ip>:<port>/swagger.
| Setting | Description |
|---|---|
serviceId |
Unique ID for this service instance (used by the web app to identify it) |
serverUrl |
BasicWeigh web server URL (e.g., https://basicscale.scaledata.net) |
signalRHub |
SignalR hub path (default: /scaleHub) |
brandsUrl |
URL to remote scale-models.json for protocol definitions |
brandsToken |
GitHub token for private repos (optional) |
| Field | Description |
|---|---|
scaleId |
Unique ID (e.g., scale-1) |
displayName |
Human-readable name |
protocol |
SMA, MettlerToledo, Custom, or Continuous (hold the connection open and read streamed frames — serial and TCP) |
ipAddress |
Scale IP address |
port |
Scale TCP port (default: 10001) |
requestCommand |
Command sent to request weight (default: W\r\n) |
pollingIntervalMs |
Poll frequency in milliseconds (default: 750) |
timeoutMs |
Socket timeout (default: 1000) |
connectionType |
TCP or Serial |
serialPortName |
e.g. COM4, /dev/ttyUSB0 — required when connectionType is Serial |
baudRate / dataBits / parity / stopBits |
Serial line settings (default 9600, 8, None, 1) |
frameParseMode |
Brand (use the brand regex / built-in parsers) or Positions |
frameWeightStart / frameWeightEnd |
0-based inclusive column range holding the weight |
frameMotionIndex / frameMotionChar |
Column carrying motion, and the character meaning "in motion" |
frameSignIndex / frameSignNegChar |
Column holding the sign, for indicators that keep it separate |
| Endpoint | Description |
|---|---|
GET /api/status/health |
Service health check with scale count |
GET /api/weight/{scaleId} |
Weight reading from a specific scale (404 until it has been polled) |
GET /api/diagnostic |
Last raw response for every active scale — raw text, hex, parsed values, timing |
GET /api/diagnostic/{scaleId} |
The same for one scale |
GET /api/serialports |
Serial ports this machine offers (for the setup screen's port picker) |
POST /api/detect |
Run format detection against frames you already captured — no hardware needed |
GET /api/status/brands |
Current brand definitions, refreshed from the remote device-definitions repo |
# Check status
sudo systemctl status scale-reader-service
# Restart
sudo systemctl restart scale-reader-service
# View logs
sudo journalctl -u scale-reader-service -f
# Stop
sudo systemctl stop scale-reader-service# Start/stop
sc start ScaleReaderService
sc stop ScaleReaderService
# View in Services app
services.msc- .NET 10.0 Runtime — installed automatically on Linux by
deploy/install.sh, and not needed at all for a--self-containedWindows publish, which bundles it - Network access to the scale(s) and the BasicWeigh web application
- TCP connectivity to scale indicators (typically port 10001), or a serial port for RS-232 indicators