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
- On-scale detectors — optional GPIO inputs at each end of the deck that tell the web app when a truck is hanging off the platform
| 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/healthPhoto-eyes or loops at each end of the weighbridge, wired to two GPIO inputs. While either one is blocked, part of the truck is off the platform and the reading on the deck is not the whole vehicle — so the service reports the scale as not occupied and the web app shows NOT ON SCALE and refuses to capture a weight, on the kiosk, the phone page and the office weigh forms alike.
Four optional fields per scale, set through the scale API or the web app's scale screen:
| Field | Meaning |
|---|---|
endDetectorPin1 |
BCM pin for the approach-end detector. Null if none wired. |
endDetectorPin2 |
BCM pin for the exit-end detector. Null if none wired. |
invertDetectorPins |
True when a blocked beam pulls the input LOW, which is how most photo-eyes with a pull-up are wired. |
detectorPullUp |
Enable the internal pull-up. Wanted for a dry contact or open-collector sensor; harmless for one that drives both states. Default on. |
The whole feature is inert until pins are set. Leave them null — or run on any machine without a GPIO chip, including Windows — and every scale reports as occupied forever, exactly as it did before detectors existed. That direction of failure is deliberate: the alternative would be an install with no GPIO refusing every weighment on the site.
All 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