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 in Devices.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

TablePurposeRetention
DevicesPer-device registry; serial, asset tag, beacon minor, assignmentPermanent
ObservationsOne row per BLE sighting with X/Y/variance90 days rolling
UnknownObservationsPSI-UUID beacons whose minor isn’t registered90 days rolling
AccessPointsCached Meraki AP metadata (mac, name, lat/lng, x/y)Refreshed daily
FloorPlansCached floor-plan image + corner georefRefreshed daily

Application interfaces

Webhook (Meraki → us)

  • GET /webhook — returns the validator string from Key Vault for the one-time Meraki handshake
  • POST /webhook — accepts Scanning API V3 payloads; auth via X-Meraki-Secret header or body secret field; 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_seen
  • GET /api/devices/{id}
  • GET /api/devices/{id}/history?days=N
  • GET /api/floor-plans, GET /api/floor-plans/{id}/image
  • GET /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-observations
  • POST /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

LayerTechnology
API.NET 8 Minimal API + EF Core 8
DatabaseAzure SQL S0 (LocalDB in dev)
SPAReact 19 + Vite 6 + Tailwind v4 + MSAL.js
AuthOpt-in Entra JWT (anonymous in dev when Auth:TenantId unset)
HostingAzure App Service (Linux, .NET 8) on existing asp-erp-migration-tool plan
SecretsKey Vault psizebratrackingkv via App Service managed identity
ObservabilityApplication Insights
InfrastructureBicep (infra/main.bicep)
CI/CDGitHub 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:5066

Production 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

TaskHow
Register a new TC53eAdmin page → “Register device” → enter serial + asset tag + auto-assigned minor → copy OEMConfig values into Intune
Onboard a beacon profileZebra OEMConfig → Bluetooth Configuration → Beacon Configuration → AltBeacon Configuration; State=Enabled, Type=AltBeacon, Airplane Mode State=Enabled
Force a Meraki cache refreshPOST /api/admin/refresh-meraki or via the Floor Map page’s “Refresh from Meraki” button
Find a missing deviceDevice list page → status “Lost” rows → click into detail for last-known X/Y
Investigate an unknown beaconAdmin page → “Unknown beacon sightings” — likely an unregistered device or a battery swap
Adjust 90-day retentionsrc/api/Constants.csObservationRetentionDays
  • Spec: docs/specs/2026-05-21-zebra-tracking-design.md in 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.