Zebra Tracking
Locate powered-off Zebra TC53e devices at the PSI Grand Rapids plant by ingesting Meraki Scanning API V3 BLE observations and mapping them to per-device beacons.
Overview
Problem this solves. TC53e mobile computers occasionally go missing at end of shift — left in a blast room, dropped behind a cabinet, parked on a tool cart that moved. Lost-device shrinkage is the immediate cost; “where was it last?” auditability is secondary value.
How it works. Each TC53e is fitted with a Zebra BLE-enabled battery (BTRY-NGTC5TC7-44MABLE-01). When the device is powered off or in Airplane Mode, the secondary BLE radio inside the battery broadcasts an AltBeacon advertisement with a PSI-wide UUID and a per-device minor ID. Meraki MR/CW access points with BLE scanning enabled hear the beacons and POST observations to our webhook via Scanning API V3. The backend filters by the PSI UUID, resolves the minor to a registered device, and writes time-stamped X/Y locations to Azure SQL.
What it doesn’t do. No live tracking of powered-on devices (the BLE battery only broadcasts when the device is off). No multi-site federation — Grand Rapids only. No worker-location dispatching.
Repository: zebra-tracking (local-only as of 2026-05-21; GHE remote pending)
Production URL (planned): https://zebra-tracking.progressivesurface.com
Architecture
Powered-off TC53e
(AltBeacon: PSI UUID + per-device ID2 minor, every ~100 ms)
│
▼
Meraki MR/CW APs (≥3 needed for X/Y triangulation)
│ ~1 POST/min per AP, batched cloud-side
▼
Meraki Cloud ── POST /webhook (X-Meraki-Secret) ────┐
│
┌─────────────────────────▼──────────────────────────────┐
│ psi-zebra-tracking (Azure App Service, .NET 8) │
│ WebhookEndpoint → MerakiPayloadParser → DeviceResolver│
│ Read API: /api/devices, /api/devices/{id}/history │
│ Floor-plan API: /api/floor-plans, /api/access-points │
│ Admin API: /api/admin/devices CRUD │
│ BackgroundServices: 90-day cleanup, daily Meraki refresh
│ SPA: served from /wwwroot (React 19 + Vite + Tailwind v4)
└─────────┬──────────────────────┬───────────────────────┘
│ │
Azure SQL DB Meraki Dashboard API
(devices, observations, (AP locations, floor plans)
floor plans, APs)
Beacon identity scheme
- UUID (PSI-wide constant):
020B5C19-1866-4F29-AA59-916A0142CED0 - ID1 / Major:
0000(reserved for future site/department dimension) - ID2 / Minor: 4 hex chars per device (
0001..FFFF), stored inDevices.BeaconMinor - Manufacturing Byte / Reference RSSI: blank — Zebra auto-calculates
The ID2 field accepts only 4 hex characters, which rules out Intune {{SerialNumberLast4}} substitution (Zebra serials contain non-hex letters). One Intune OEMConfig profile per device is the chosen approach; the SPA’s device-detail page surfaces the values for IT to paste in.
Data model
| Table | Purpose | Retention |
|---|---|---|
Devices | Per-device registry; serial, asset tag, beacon minor, assignment | Permanent |
Observations | One row per BLE sighting with X/Y/variance | 90 days rolling |
UnknownObservations | PSI-UUID beacons whose minor isn’t registered | 90 days rolling |
AccessPoints | Cached Meraki AP metadata (mac, name, lat/lng, x/y) | Refreshed daily |
FloorPlans | Cached floor-plan image + corner georef | Refreshed daily |
Application interfaces
Webhook (Meraki → us)
GET /webhook— returns the validator string from Key Vault for the one-time Meraki handshakePOST /webhook— accepts Scanning API V3 payloads; auth viaX-Meraki-Secretheader or bodysecretfield; returns 200 on success, 401 on bad secret, 500 to trigger Meraki retry on SQL outage
Read API (SPA / IT tools)
GET /api/devices— list with last_seenGET /api/devices/{id}GET /api/devices/{id}/history?days=NGET /api/floor-plans,GET /api/floor-plans/{id}/imageGET /api/access-points?floorPlanId=...GET /api/health
Admin API
POST /api/admin/devices,PUT /api/admin/devices/{id},DELETE /api/admin/devices/{id}(soft delete =Active=false)GET /api/admin/unknown-observationsPOST /api/admin/refresh-meraki— manual trigger for the daily Dashboard refresh
SPA pages
/— device list with status badges (Recent / Stale / Lost)/devices/:id— device detail + 7-day history + OEMConfig copy-paste values/map— floor plan with AP markers (cyan) and device pins (amber)/admin— Devices CRUD + unknown-beacon sightings
Technology stack
| Layer | Technology |
|---|---|
| API | .NET 8 Minimal API + EF Core 8 |
| Database | Azure SQL S0 (LocalDB in dev) |
| SPA | React 19 + Vite 6 + Tailwind v4 + MSAL.js |
| Auth | Opt-in Entra JWT (anonymous in dev when Auth:TenantId unset) |
| Hosting | Azure App Service (Linux, .NET 8) on existing asp-erp-migration-tool plan |
| Secrets | Key Vault psizebratrackingkv via App Service managed identity |
| Observability | Application Insights |
| Infrastructure | Bicep (infra/main.bicep) |
| CI/CD | GitHub Actions: build → publish → deploy to staging slot → swap |
Single Azure App Service serves both the API and the SPA — the SPA builds into src/api/wwwroot, the API serves it via UseStaticFiles + MapFallbackToFile("index.html") for React Router.
Pilot / current state (as of 2026-05-21)
- v0.5.0 tagged locally; 5 sequential alpha tags (v0.1 backend → v0.2 SPA → v0.3 Meraki → v0.4 deploy → v0.5 auth)
- 11/11 unit + integration tests passing (parser, device resolver, webhook ingest, read API)
- End-to-end smoke test passing against LocalDB with the sample Meraki payload
- Not yet deployed to Azure; no GHE remote configured
- Next: assign Intune OEMConfig profile to a real TC53e, validate Meraki sees the beacon, deploy backend to staging slot, point Meraki Scanning API V3 at the staging webhook URL
Local development
Prereqs: .NET 8 SDK, Node 20+, SQL Server LocalDB.
# One-time
cd src/api
dotnet ef database update
cd ../web
npm install
# Dev (two terminals)
cd src/api; dotnet run --urls=http://localhost:5066
cd src/web; npm run dev # http://localhost:5173
# Prod-style (single origin)
cd src/web; npm run build
cd ../api; dotnet run --urls=http://localhost:5066 # http://localhost:5066Production deploy
See infra/README.md and .github/workflows/README.md in the repo. Push to main triggers build → staging-slot deploy → health check → swap to production.
Operations
| Task | How |
|---|---|
| Register a new TC53e | Admin page → “Register device” → enter serial + asset tag + auto-assigned minor → copy OEMConfig values into Intune |
| Onboard a beacon profile | Zebra OEMConfig → Bluetooth Configuration → Beacon Configuration → AltBeacon Configuration; State=Enabled, Type=AltBeacon, Airplane Mode State=Enabled |
| Force a Meraki cache refresh | POST /api/admin/refresh-meraki or via the Floor Map page’s “Refresh from Meraki” button |
| Find a missing device | Device list page → status “Lost” rows → click into detail for last-known X/Y |
| Investigate an unknown beacon | Admin page → “Unknown beacon sightings” — likely an unregistered device or a battery swap |
| Adjust 90-day retention | src/api/Constants.cs → ObservationRetentionDays |
Related
- Spec:
docs/specs/2026-05-21-zebra-tracking-design.mdin the repo - Backend plan:
docs/plans/2026-05-21-zebra-tracking-backend.md - Companion devices: TC52 fleet documented in NFC TAP Bridge and PRGJSMES. TC53e is the successor model.