SUPPORT PORTAL
Everything you need to get the most out of QDat.io: detailed features, the QDatDroid report, a frequently-asked-questions guide, the API reference, the Cooldat data-exchange standard, and the backend changelog.
One trusted plane from edge devices to executive dashboards.
The Android field client that turns a phone or handheld into an RFID/NFC reader.
Cold-chain temperature intelligence on a passive tag.
Field-ops tooling and the Spatiotemporal Digital Product Passport.
A guided tour of the QDat.io web interface — captured from tapdpp.qdat.io
A guided tour of the QDat.io web interface — dashboard, readers, tags, temperature logging, Digital Product Passport, admin panel, and licenses. Captured from the live demo instance at tapdpp.qdat.io.
Section 1
QDat.io is a cloud-based RFID asset tracking and IoT sensor platform connecting physical RFID/NFC readers and smart tags to a central management system. It provides real-time inventory tracking, temperature data logging, geolocation mapping, and Digital Product Passports — organized into multi-tenant departments.
Track thousands of UHF and NFC tags in real-time across multiple readers and locations.
Monitor passive UHF sensors and Axzon OPUS battery dataloggers with full charting history.
Visualize tagged assets on an interactive Leaflet map with clustering and GPS updates.
Public-facing DPP pages per EPC with product, manufacturer, and sustainability data.
Real-time stream of tag detections with dBm signal strength, reader ID, and timestamps.
Android field reader app with device-keyed licensing — any Android phone becomes a scanner.
The live instance at tapdpp.qdat.io runs in Demo Mode — a public sandbox with periodic resets. Pre-loaded with 1,204 tags, 14 readers, 24,000+ temperature readings, and 2,299 recent detection events.
Section 2
The landing page after login — real-time KPI tiles, recent tag detections, and quick-action shortcuts for the active department.
Section 3
All physical RFID and NFC reader hardware — remotely controlled, configured, and monitored via a dedicated Control panel.


| Hardware | Role | Connection States |
|---|---|---|
| Zebra FX9600 (UHF Fixed) | INVENTORY — active tag scanning & data logging | Connected · Standby · Error |
| Zebra FX7500 (UHF Fixed) | MONITOR — real-time detection stream | Disconnected · No MQTT |
| QDatDroid (Android phone/tablet) | ||
| ET401-NFC / TC22R (Handheld) |
Inventory Control — Start/Stop continuous scanning, 5-second burst read | Configuration — Antenna power: LOW 15 dBm / MEDIUM 25 dBm / HIGH 30 dBm, state control | Data Management — Export tag data, clear reader memory | Live Inventory — Real-time detection view
Section 5 — Featured
QDat supports two categories of temperature-sensing tags, each with interactive charts, configurable alarm thresholds, tabbed data views, and full asset management.
GENERIC · TEMPERATUREA passive UHF tag continuously sampled by a nearby reader (~1 reading/minute). No battery required — powered inductively by the reader's RF field. The reader streams readings directly to QDat. Example: DEMOTEMP00000000000 — 24,446 readings · Alarm: −10°C / 30°C
TEMPERATURE_DATALOGGERA battery-powered UHF RFID datalogger that records temperature autonomously on-chip, even with no reader present. When scanned, the full stored log is uploaded to QDat in one read. Template: Cooltag-Opus. Example: 5201F25100009186 · Battery: 4.09 V · 21 readings · Alarm: 1°C min / 100°C max · Public URL: https://tapdpp.qdat.io/5201F25100009186


| Tab | Content |
|---|---|
| Measurements | Interactive time-series chart — Raw / Minute / Hourly / Daily aggregation, drag-to-zoom, red dashed alarm threshold lines |
| Tag Readings | Complete tabular log of every timestamped temperature reading with sequential index and timezone selector |
| Instant Temp Event | Snapshot of the most-recent single temperature reading fetched live from the tag |
| Battery Voltage | Voltage-over-time chart and log table (Axzon OPUS only) |
| Inventory | History of which readers detected this tag and when |
| Details | EPC, TID, URL, model, alarm thresholds (min/max), asset description/photo, building/floor/zone/room, management fields |
| Map | Last-known GPS coordinates pinned on interactive map |
Fetch Info — pulls live sensor data from the tag via a nearby reader | Read Data — retrieves full stored log from chip memory | Stop Reader — halts the active read loop. Tag must be in range of a connected reader.
Section 6
Interactive geospatial view of all located assets. 1,145 of 1,204 tags have GPS coordinates assigned and appear on the map.
Tags are grouped into numbered cluster badges (e.g. "1134"). Clicking drills in to individual pins. Each pin shows the tag's last timestamp, coordinates, temperature reading, and a link to its detail page. Cluster radius is configurable (default 25 km). The right panel paginates all 1,145 located tags across 23 pages.
Section 7
Live-refreshing feed of all RFID tag detection events from the last 24 hours, showing signal strength and reader attribution for every detection.
Each event shows: tag name (or auto-discovered EPC), full EPC code, signal strength in dBm (typically −40 to −70 dBm), timestamp, and the detecting reader's name. Refreshes every 5 seconds (configurable: 5s / 10s / 30s / manual). Searchable by tag name or EPC. Timezone-adjustable.
Section 8
Complete system administration hub — departments, users, branding, API, Digital Product Passport authoring, and platform tooling.
| Module | Description |
|---|---|
| Departments | Create and manage organizational departments with isolated data scopes |
| User Management | Manage user accounts, passwords, and role-based access control |
| RFID Readers | Global reader configuration and monitoring (admin-level view) |
| Tags | Tag templates, operational categories, SKUs — backbone of the tag registry |
| Branding | Upload and manage department logos shown in the platform UI |
| System Administration | Database inspector, Swagger API Explorer, API tokens, Backup/Restore, Audit log |
| Demo Mode | Run cluster as a public sandbox with configurable periodic data resets |
| Digital Product Passport | 5 passport templates, tag bindings, DPP records (product/manufacturer/sustainability), public tag URLs |
| IP Bans | Throttle rapid failed-login bursts; configure thresholds and review active bans |
| Licenses | Device-keyed QDatDroid licenses: issue, extend (+30 days), revoke |
Each tag gets a public URL: https://tapdpp.qdat.io/<EPC>. The DPP admin manages Passport Templates (layouts + branding + auto-bind rules), Tag Bindings, per-EPC DPP Records (product description, manufacturer contact, representative, sustainability data), and reusable contact block libraries.
Section 9
Device-keyed licensing for the QDatDroid Android mobile reader app. Each license is bound to a hardware Android Device ID and is global across all departments.
| Field | Description |
|---|---|
| Android Device ID | Unique hardware ID of the Android device to be licensed |
| Product | QDatDroid (current product) |
| Max Tags | Maximum tag count allowed under this license (0 = unlimited) |
| Valid Days | License duration in days (leave blank = perpetual) |
| Subscription | Flag for recurring subscription-type licenses |
| Actions | View QR code for device onboarding · Extend +30 days · Revoke |
Section 10
Key technology components as observed directly in the live platform.
UHF EPC Gen2 (ISO 18000-6C) and NFC (ISO 14443). Fixed infrastructure and mobile handheld readers both supported.
MQTT protocol for reader-to-cloud communication. Readers connect via TCP/IP; broker handles real-time event streaming to all subscribers.
Battery UHF RFID datalogger (4.09V). Logs temperature autonomously on-chip at configurable intervals; full log uploaded on next RFID scan.
Leaflet.js + OpenStreetMap tiles. Cluster-based rendering handles thousands of tag positions efficiently.
Full REST API with interactive Swagger/OpenAPI explorer. API tokens issued and managed via System Administration.
Department-scoped data isolation. Tags, readers, events, and DPP records are all scoped and switchable per department.
| Component | Details |
|---|---|
| Platform URL | tapdpp.qdat.io |
| Fixed UHF Readers | Zebra FX7500, Zebra FX9600 |
| Mobile Readers | QDatDroid (Android), ET401-NFC, TC22R |
| Temperature Tags | Passive UHF temp sensors · Axzon OPUS battery dataloggers (Cooltag-Opus template) |
| UHF Tag Chips | Impinj M730, M750, Monza R6 · NXP Ucode 8 · EM Micro · Axzon OPUS |
| Protocol | MQTT (reader ↔ cloud) + REST API (clients ↔ cloud) |
| Maps | Leaflet.js + OpenStreetMap |
| API Docs | Swagger/OpenAPI interactive explorer (Admin → System Administration → API Explorer) |
| Mobile App | QDatDroid (Android) — device-keyed license, appears as QDATDROID reader type |
| Auth | Username + password login · API tokens · IP ban protection against brute force |
| DPP Public URLs | https://tapdpp.qdat.io/<EPC> — one publicly accessible page per tag |
| Built by | Meerv |
Native desktop app for Windows, macOS, and Linux · version 1.1.0
The QDat.io Desktop Dashboard delivers near feature parity with the web dashboard, in the ease of use of a dedicated desktop application. Its defining trait: 100% of its functionality is made accessible through the public QDat.io API. Everything you see and do in the desktop app is built on the same documented REST API.
That is a live demonstration of the integration opportunities: if you see it on the QDat.io Dashboard web interface, and you see it in the desktop app, then the same UX and UI can be brought into any WMS, ERP, or other software — because it all rides on QDat.io's comprehensive API.
Capabilities, technical highlights, and the full release-notes history of the Android client
Android · RAIN RFID / NFC field reader client
QDatDroid turns a phone, tablet, or Zebra handheld into a RAIN RFID / NFC reader connected to the QDat.io plane — scan-to-provision, spatiotemporal event capture, and live MQTT publish, online or offline.
Everything QDatDroid captures in the field.
Technical highlights
Full history · 62 releases, newest first.
Licensing comes to QDatDroid: request a trial or a full license right from the app, activate it, and arm tags up to your licensed allowance — with a cleaner, QDat.io-branded setup screen.
Connect QDatDroid to your QDat.io account to attach photos and asset details to tags, plus hands-free auto-isolate of the closest tag.
A quick polish on the Tag Locate graph after 2.0.16.
docs/ZEBRA-SDK-2.0.5.275-DEVICES-EN.md for the full list and the Android-version requirements per device.The Tag Locate graph gets a full visual redesign, OPUS Arm and Read Logger handle saturated tags much better, the battery voltage stops flickering, and the reader icon no longer lies about being disconnected.
A short polish pass — three small fixes on the main-screen contextual menu and the inline action bar.
A tidy-up of the OPUS tag screens. OPUS Status and Read Logger are now two clearly different tools.
A focused cleanup release. Six small-to-medium fixes that landed across the day plus a Tag Locate UX overhaul tonight.
EM4425 NFC + UHF dual-radio tag support — read and write NDEF URLs from the UHF side, see them in the tag list, and watch them ride MQTT. Plus a quality-of-life pass on the main screen for single-tag flows, and a full overhaul of MQTT Settings and Tag Locate carried in from the in-progress 2.0.10 work.
https://tapdpp.qdat.io/<EPC> into that same NDEF region. The written URL is then visible to any NFC-capable phone with a tap. Uses a pre-clear pattern (zeros the region first) because the EM4425 silently fails to overwrite existing data in one shot.URL: line below the TID, in primary green, shows any captured NDEF URL — populated by NFC taps (NFC rows) or by the EM4425 URL / DPP Write actions (UHF rows). In list view the URL line spans the full row width so long URLs aren't truncated.url field — matching the shape NFC scans already use. The backend can now bind a single url column across UHF + NFC scans of the same physical chip.Internal housekeeping release — no user-visible changes. (The in-app Release Log content for 2.0.7 was reorganized after a parallel-branch merge had duplicated some sections.)
0000 and FFFF) are stripped cleanly.qdat.dev to qdat.io.X ms (10–320 ms in steps of 10), instead of bogus values like 1 (~3 ms).com.pulrtechnologies.cooldat to com.meerv.qdat. Existing installs cannot be upgraded in place — they must be reinstalled.qdat and the legacy cooldat envelope.CHANGELOG.md in the repository root.QDat.io is the Physical AI Memory plane for every thing — a trusted data layer that takes events from edge devices (RFID/NFC readers, temperature dataloggers, ERP feeds, operator actions) and turns them into a verifiable, immutable, queryable history that logistics, manufacturing, and compliance teams can act on.
QDatDroid (the Android reader/field client), Cooldat® and CoolTag (cold-chain temperature intelligence), Heli (field-ops companion), and the Spatiotemporal Digital Product Passport (SDPP). They all feed the same QDat.io data plane.
A Tag is any tracked physical thing — an RFID/NFC chip, an OPUS temperature datalogger, or a generic item. As of release 2.0.0 the entity formerly called “Sensor” was renamed to “Tag” across the API, database, and apps. The UI and MQTT stream already spoke in “tags”; this just brought the code into agreement. External integrators on the old /sensors paths must move to /tags.
A public web page bound to a physical tag that resolves when anyone scans it — no login required. Operators author the sections (identity, specs, location, provenance, documents, materials, contact) from a template, and a closed field allowlist means adding data to a tag never silently exposes it.
Each passport template can carry rewrite rules with an optional geofence (within X metres of a point) and/or an optional time window (X minutes/hours/days since the tag was last seen). When both are set the rule fires only if every condition holds — a spatiotemporal AND — and the visitor is redirected accordingly. On ties, a geofenced rule beats a time-only rule.
A device requests a trial or a managed licence keyed to its stable device_id. A trial is auto-granted; a managed request waits for an admin to approve it. When the cluster is in demo mode the trial response also returns MQTT broker credentials and the device's topics inline, so the app is fully connected after one call. Otherwise it returns a licence-only response and no broker credentials are minted.
Yes. Every event is time-stamped, cryptographically signed, and written to an immutable audit log. Licence lifecycle events (created / revoked / deleted) and authentication events are recorded with the acting admin, client IP, and metadata.
Yes — the site, apps, and product surfaces are bilingual (English / French), with Spanish on several internal surfaces. Use the EN / FR toggle in the top navigation.
Public demo clusters (for example demo1 and tapdpp) let a QDatDroid device self-provision a trial and MQTT credentials anonymously. To arrange a guided walkthrough or a production deployment, contact us at hello@qdat.io.
Signed APKs for QDatDroid and the companion clients are linked from the relevant product pages. From QDatDroid, request access on the form to receive the current build.
See our Privacy Policy for how Meerv Inc. (operating as QDat.io) collects, uses, and safeguards information. We do not sell or rent personal data.
QDat.io REST API · generated from the live OpenAPI specification
https://api.tapdpp.qdat.ioAuth: OAuth2 password bearer (Bearer token)v2.0 · 167 endpointsInteractive docs Browse the QDat.io REST API — generated from the live OpenAPI specification. To try requests live (Swagger UI), open the interactive docs.
Cooldat Cold-Chain Data Exchange Format · open, vendor-neutral profile
CDX-1 is a vendor-neutral JSON wire format for UHF RFID temperature dataloggers — with first-class support for Axzon OPUS-family chips (“Cooldat” dataloggers). It defines a single envelope any acquisition app can emit and any back-end can ingest, without bespoke per-vendor adapters. It already runs in production as the contract between the QDatDroid publisher and the qdat.io ingester.
Working draft · extracted from the live QDatDroid → qdat.io implementation (tapdpp.qdat.io), 2026-05-27.
Cumulative capabilities, from presence-only to the bidirectional closed loop.
L0 | Identifier-only | Reads only the chip identifier (EPC/UID) and reports presence. |
L1 | Inventory | Adds RSSI, GPS, optional chip family and state. |
L2 | Sensor snapshot | Adds live temperature, battery, on-chip RSSI and alarms. |
L3 | Historical dump | Adds the device-side flash-memory dump (READ batches + complete). |
L4 | Closed loop | Adds the ingester's TAG_REGISTERED downlink, including DPP-rule redirect. |
Every CDX-1 payload is a JSON object at the root. Key rule: an absent field means “unknown” — never a typed null.
{
"command": "SCAN" | "READ",
"status": "SUCCESS" | "ERROR",
"command_id": "<uuid>",
"parameters": { ... }, // present on SCAN
"epc": "<hex>", // present on READ
"data": [...] | {...}, // shape varies by command
"batch_timestamp": <int> // SCAN batch only
}CDX-1 stays simpler than EPCIS 2.0 — it's an on-the-wire format, not a query model.
| CDX-1 concept | Closest standard | Gap |
|---|---|---|
epc (UHF) | EPCglobal Tag Data Standard 1.13 | None — same hex string, MSB-first. |
cin_brand / cin_decimal | RAIN CIN+FFe encoding | None — CDX-1 just decodes it. |
alarms[] | GS1 EPCIS 2.0 sensorReport.exception | Stricter vocabulary; CDX-1 enumerates 6 names. |
temperature, battery_voltage | EPCIS 2.0 sensorReport type/value/uom | Direct fields + implicit unit; EPCIS more verbose. |
READ batch | No equivalent | Flash-dump idiom for intermittently-connected dataloggers. |
TAG_REGISTERED downlink | No equivalent | Vendor-specific in practice (SmartLink, LIBERO Connect). |
The “with samples” PDF includes a runnable corpus of every envelope.
01-scan-single-opus.jsonSCAN single — live Axzon OPUS datalogger02-scan-batch-mixed.jsonSCAN batch — three tags, mixed chip families03-scan-with-alarms.jsonSCAN with alarms — OVER_TEMPERATURE + LOW_BATTERY + TAMPER04-read-historical-batch.jsonREAD batch — flash dump with device config05-read-complete.jsonREAD complete — terminator06-tag-template.jsonTag-template binding — live Cooltag-Opus07-tag-registered-ack.jsonTAG_REGISTERED downlink — registration ackReleases 2.0.0 → 2.2.4 · deployed to tapdpp.qdat.io and demo1.qdat.io
Releases 2.0.0 → 2.2.4 · 1 June 2026
Scope: all backend changes introduced from the 2.0.0 major release through the 2.2.4 patch of QDAT (qdat.io). This spans four feature areas delivered in sequence: the Sensor → Tag rename (the one breaking change), the Digital Product Passport surface and demo-mode experience (2.0.0), the DPP rules engine (2.1.0), and the License Manager that lets the QDatDroid Android client obtain a licence and the MQTT broker credentials it needs to talk to a cluster (2.2.0 / 2.2.1).
Deployed to: tapdpp.qdat.io and demo1.qdat.io.
Compatibility: one breaking change (the Sensor → Tag rename in 2.0.0, flagged below); everything after it is additive. Eight additive database migrations across the range, plus the rename migration.
Release map:
| Release | Date | Headline |
|---|---|---|
| 2.0.0 | 2026-05-24 | Sensor → Tag rename · Digital Product Passport · demo-mode polish · login IP bans |
| 2.1.0 | 2026-05-25 | DPP rules engine (geofence + time + spatiotemporal) · ingester rule evaluation · admin polish |
| 2.1.1 | 2026-05-31 | Documentation only (no code, schema, or API change) |
| 2.2.0 | 2026-06-01 | License Manager · MQTT credentials on the trial API · combined provisioning QR |
| 2.2.1 | 2026-06-01 | Managed-request hardening · new-vs-renewal console context |
| 2.2.2 | 2026-06-01 | Always log request-license from a trial device · expire trial on approval |
| 2.2.3 | 2026-06-01 | Re-bind an existing reader on a provisioning device-id conflict |
| 2.2.4 | 2026-06-01 | Regenerate MQTT credentials when re-binding a reader |
Major release — advertised to QDat.io clients (QDatDroid, Heli.app, TapDPP). Introduces the Digital Product Passport surface, a curated demo-mode experience, and per-IP rate limiting on login. The Sensor → Tag rename is bundled here so external integrators upgrade past the breaking change in lockstep.
The entity Sensor was renamed to Tag across the entire stack. The end-user UI, MQTT topic stream, and log strings already spoke in "tags"; this brings code, API, and DB schema into agreement.
sensor, sensorreading, sensorevent, sensortemplate, sensoralert → tag, tagreading, tagevent, tagtemplate, tagalert. Foreign-key columns sensor_id → tag_id. Field Sensor.tag_type (RFID chip family) renamed to Tag.chip_type to free the slot for the entity-type enum (Sensor.sensor_type → Tag.tag_type). PostgreSQL enum types renamed in lockstep. Migration: o5k6l7m8n9o0_rename_sensor_to_tag./sensors, /sensor-templates → /tags, /tag-templates. Pydantic schemas Sensor* → Tag* (response payload keys change)./sensors, /sensor-detail, /sensor-form, /sensor-templates → /tags, /tag-detail, /tag-form, /tag-templates. i18n namespace sensors → tags (translated values were already "Tag"/"Tags" in en/fr/es; only key names changed).qdat-cli sensor … → qdat-cli tag ….External clients consuming the old paths must update. The auto-generated frontend SDK regenerates on npm run generate-client and produces the new identifiers automatically.
https://<dpp_host>/<EPCID-or-UID> (no auth). Every QDatDroid-provisioned tag URL now renders an operator-authored page with HERO / IDENTITY / SPECS / LOCATION / PROVENANCE / DOCUMENTS / MATERIALS / CONTACT / QR / CUSTOM_HTML / CUSTOM_JSON_LD sections. The section list and per-section visibility are template-driven; visible fields are gated by a closed allowlist (DppExposableTagField) so adding a column to Tag never auto-leaks it.chip_manufacturer, cin_brand, nfc_ic_manufacturer, tag_type). Exactly one default per organisation (partial unique index). Bound to tags via Tag.dpp_template_id, with dpp_binding_locked honoured by the ingester auto-bind hook./admin-dpp with three sub-pages: templates (list, name, default badge, section count, branding swatch, auto-bind matchers), bindings (every tag with its bound template, lock state, clickable public URL), and settings (edit Organisation.dpp_host, view DPP_PUBLIC_ENABLED status and rate-limit).PassportTemplateRepository.find_auto_bind_match() (same priority axes as the operational TagTemplate matcher) and binds the tag's dpp_template_id unless dpp_binding_locked is set.forbid_in_demo_mode, so a visitor clicking through gets a 403 toast rather than a destructive action.mqtt_credentials_active=False so they look like decommissioned hardware. Each tag gets a 4 000-row TagReading backfill at ~20 °C with Montréal coords + jitter (~2.8 days of history).system_setting, restored on disable); every seeded tag's Tag.url is populated as https://<host>/<EPCID> so demo public DPP pages just work.__root.tsx so it shows on the login screen too.After N failed logins from one IP in a rolling M-second window, an IpBan row is inserted with banned_until = now() + ban_seconds. The /login/access-token endpoint refuses banned IPs with HTTP 429 + Retry-After before any password verification runs. Tunables (enabled, attempts_threshold, window_seconds, ban_seconds) live in system_setting and are editable at a new admin page /admin-ip-bans, which also lists active bans with a per-row Unban action.
Reader.lat / lon typed float | None on ReaderBase (and therefore ReaderRead), matching the nullable DB column — without this every reader-list endpoint threw ResponseValidationError for any reader that hadn't been geolocated.AuthEventTypeEnum gained the four DEMO_* values (DEMO_MODE_ENABLED, DEMO_MODE_DISABLED, DEMO_PURGE_RUN, DEMO_VISITOR_SESSION_CREATED) that migration n0o1p2q3r4s5 had already added to Postgres; without them every demo lifecycle action threw AttributeError inside record_auth_event.DemoService._create_visitor_user no longer calls int(Flag) (rejected by Python 3.10's Flag); uses .value so visitor users get their ResourceTypePermission rows.@demo.qdat.io instead of @demo.local (Pydantic's EmailStr rejects RFC 6762 reserved TLDs).Reader.role (NOT NULL constraint).BACKEND_CORS_ORIGINS now allows the apex host too.| Revision | Description |
|---|---|
o5k6l7m8n9o0 | Rename sensor* tables/enums/FKs to tag*; Sensor.tag_type → Tag.chip_type. |
o1p2q3r4s5t6 | Create passporttemplate; add tag.dpp_template_id / dpp_binding_locked / dpp_overrides; partial unique (organisation_id, url) WHERE url IS NOT NULL; organisation.dpp_host; partial unique default per org. |
p2q3r4s5t6u7 | Create ip_ban with indexes on ip_address and banned_until; FK created_by → user(id) ON DELETE SET NULL. |
DPP_PUBLIC_ENABLED (kill-switch — public resolver returns 404 when false), DPP_RATE_LIMIT, DPP_CACHE_MAX_AGE_SECONDS.IpBan* admin endpoints under /admin/ip-bans (superuser-only, include_in_schema=False).cert-manager.yaml Certificate gains APEX_HOST_PLACEHOLDER so the bare instance host is in the cert SANs.Minor release. Adds the DPP rules engine (geofence + time + spatiotemporal AND), a map-driven editor, an MQTT TAG_REGISTERED redirect handoff, and a batch of admin-UI polish. No breaking changes; two additive migrations.
geo block (within X metres of lat/lon, anchored on tag.last_lat/last_lon) and an optional time block (X minutes/hours/days since tag.last_seen_at). When both are set the rule fires only if every condition holds — spatiotemporal AND. On ties, a rule carrying a geofence beats a time-only rule. The public response now carries redirect_url + redirect_reason (geo / time / spatiotemporal); the SPA hands the visitor off via window.location.replace. Migration v8w9x0y1z2a3.TagTemplate row rather than duplicating the matching axes. It is the highest-priority axis in PassportTemplateRepository.find_auto_bind_match, threaded through both auto-bind call sites (REST upsert + MQTT ingest)._process_inventory_result_inner reuses _evaluate_template_rules after the SCAN writes the freshly-scanned GPS + last_seen_at to the tag. When a rule fires, the TAG_REGISTERED ack on devices/<topic_id>/command carries the rule-corrected redirect_url + redirect_reason, so clients can override the NFC-encoded URL the visitor would otherwise land on./admin-dpp/templates new-rule UI gains Photon civic-address autocomplete, a Leaflet OSM map with draggable marker and tunable radius circle, and synced lat / lon / radius inputs.PUT /passport-templates/{id}), bulk-select + delete, and a rules editor with the same bulk-select pattern./admin, and the matching backend mutations gain a forbid_in_demo_mode dependency so a CLI poke gets a matching 403 instead of mutating sandbox state.{enabled, …, error?: {code, message}}) on every path, catching in-flight DB rollbacks that used to drop CORS headers and leave the dashboard with an unactionable "blocked by CORS" error.dpp_host editor) already lives on the org-edit form.Cooltag-Opus / Cooltag-NFC-V, Passport Templates auto-bound to them carrying two seeded rules (geofence at Montréal City Hall + 5-min elapsed), and rebinds the seeded OPUS Datalogger / NFC-V demo tags through that hierarchy so tapping either tag exercises the rules engine end-to-end.Manufacturer / Representative / DppRecord (DppRecord first because it FKs into both); re-enable previously crashed with a UniqueViolation on ix_manufacturer_org_name.default_factory=uuid4 — they were default=None, so the seeder sent NULL into NOT-NULL PK columns and the scheduled auto-purge silently retried the crash every minute.TAG_REGISTERED ack alongside). Migration u7v8w9x0y1z2.| Revision | Description |
|---|---|
u7v8w9x0y1z2 | Drop NOT NULL on tagevent.lon/.lat and tagreading.lon/.lat. |
v8w9x0y1z2a3 | Add passporttemplate.auto_bind_tag_template_id (FK, ON DELETE SET NULL) and passporttemplate.rules (JSON, NOT NULL, default []). |
Patch release — no code, schema, or API changes. It also synchronises the version across VERSION, frontend/package.json, backend/pyproject.toml, and cli/pyproject.toml (previously diverged at 1.2.0 / 2.0.0 / 2.1.0). Added docs:
Organisation.parent_id top-level/department model (UI-only scaffolding, not traversed), dpp_host resolution, and the protected Default/Demo orgs.tapdpp (on) vs qcma1 (off) comparison.GET /demo/banner, POST /demo/visitor-session, POST /demo/self-provision-reader), with reference Kotlin and expiry handling.Minor release. Builds out the License Manager, the server-side control plane that lets the QDatDroid Android client obtain a licence and the MQTT broker credentials it needs to talk to a cluster. No breaking changes; two additive migrations (plus the audit-enum migration carried in 2.2.1's chain head).
A QDAT cluster previously had no concept of a *device licence*. The License Manager adds one. It is single-instance and device-keyed, not scoped to an organisation: a licence binds to a device_id — the stable per-install identifier the QDatDroid client sends — rather than to a user or department.
The feature has two surfaces, both mounted under the /api/v1/licenses prefix:
1. A public, device-facing API (no authentication, rate-limited) that the Android client calls directly: request a trial, validate, revoke, sync telemetry, and request a managed licence. 2. An admin API (superuser only, hidden from the public OpenAPI schema) that backs the /licenses console: mint, list, update/revoke, delete, and approve or reject managed requests.
A device's full life-cycle is therefore: **request → (auto-grant trial *or* admin approval) → validate on launch → sync usage → renew / revoke.**
Three new tables were added (backend/app/models/license.py).
licenseOne licence row, bound to a device_id.
| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key |
key | string, unique | Human-facing key the app stores and re-presents (QDAT-XXXX-…) |
device_id | string, indexed | Primary bind key — the stable per-install id |
mac_address | string, nullable | Secondary, unreliable hint (randomised on modern Android); never the sole gate |
customer_name, customer_email | string, nullable | Lead / owner info |
product, product_en | string, nullable | Product label |
status | string | active / expired / revoked |
is_trial, is_activated, is_subscription | bool | Licence flags |
max_tags | int | Cap on OPUS tags the device may arm |
expires_at | timestamptz, nullable | null = perpetual |
arm_count, read_count, app_version, tier, reader_model | — | Telemetry pushed by sync-license (best-effort, last-write-wins) |
created_at, updated_at | timestamptz | — |
license_leadA sales lead captured when a trial is requested (email, name, product, optional claim_token minted by the qdat.io download page to join a web lead).
license_requestA managed licence request raised by a device. The device POSTs its device_id; the row sits pending until a superuser approves it (minting a License bound to the same device_id) or rejects it. The device re-POSTs the same endpoint to poll and, once approved, download the licence. Carries device telemetry captured at request time (app_version, reader_model, tier, requested_max_tags) to help the admin decide, plus an admin note surfaced back to the device and the license_id it resolved into.
All five endpoints are unauthenticated and rate-limited to 30 requests/minute per client. Field names match exactly what the QDatDroid client serialises/deserialises.
| Method & path | Purpose |
|---|---|
POST /api/v1/licenses/request-trial | Auto-grant a trial licence for a device_id; capture the lead. Also provisions MQTT credentials — see §4. |
POST /api/v1/licenses/request-license | Lodge a managed request (admin must approve). Idempotent poll endpoint. |
POST /api/v1/licenses/validate-license | Verify a licence key on app launch; returns status, expiry, days remaining. |
POST /api/v1/licenses/revoke-license | Device-initiated revoke of its own licence. |
POST /api/v1/licenses/sync-license | Push usage telemetry (arm/read counts, app version, tier, reader model). |
Every response embeds a license object (key, product, status, customer, max_tags, expires_at, days_remaining, the trial/activated/subscription flags). The trial response additionally carries the flat mqtt and device blocks described next.
This is the headline integration change. POST /request-trial now does more than mint a licence — when the cluster is in demo mode, it also provisions a broker identity for the device and returns it inline, so QDatDroid is fully connected after a single call.
On a successful trial the response carries two flat top-level blocks alongside license (the same shape as the combined provisioning QR):
host, port, wss_port, tls, username, password.topic_id and the three resolved topics: devices/<topic_id>/{command,response,data}.Mechanics and safety:
DemoService.provision_tapdpp_reader): a Demo-org reader plus a Mosquitto dynamic-security user. It is idempotent per `device_id`, and the password rotates on every call.Operational note. Because trial provisioning is anonymous and demo-mode gated, any cluster running as a public demo (e.g. demo1, tapdpp) will mint Demo-org MQTT credentials to anonymous callers by design. This is an accepted trade-off for a self-purging demo sandbox.
/licenses launches an optional 3-step flow inside /reader-form: respond to the request (shows the Android Device ID + timestamp, sets the licence terms and approves), create the RFID reader using the Device ID as a quasi-MAC (device_model = QDATDROID, no MAC-format validation) with MQTT credentials, then display one large combined QR. The plain reader form is unchanged when no requestId is present.license + mqtt + device (the devices/<topic_id>/{command,response,data} topics) so the client provisions licence and broker connection in one scan. Documented in docs/standards/QR_ENVELOPE_CONVENTION.md.The admin surface (superuser only, include_in_schema=False) backs the /licenses console.
| Method & path | Purpose |
|---|---|
GET /api/v1/licenses/ | List licences (with days_remaining, customer email). |
POST /api/v1/licenses/ | Hand-mint a licence (201). |
PATCH /api/v1/licenses/{id} | Update — revoke or extend (+N days), change max_tags. |
DELETE /api/v1/licenses/{id} | Permanently delete — only a revoked licence may be deleted; non-revoked returns 409. |
GET /api/v1/licenses/requests | List managed requests (enriched — see §8). |
GET /api/v1/licenses/requests/{id} | Fetch one managed request (backs the provisioning wizard's step 1). |
POST /api/v1/licenses/requests/{id}/approve | Approve — mints a licence bound to the request's device_id. |
POST /api/v1/licenses/requests/{id}/reject | Reject — the note is surfaced back to the device. |
The console gained per-row and bulk delete of revoked licences, Created and Days-left columns, a customer Email column, and a horizontally scrollable table.
License life-cycle events are now written to the auth audit trail (visible at /admin-audit). Three values were added to the autheventtypeenum Postgres enum:
Each event records the acting admin (null for anonymous trials), the client IP, and metadata (key, device_id, max_tags, source).
To let the same machinery provision the Android device as a "reader", two backing changes were made to the reader model:
FX7500 / FX9600 / AR.| Revision | Description |
|---|---|
o5p6q7r8s9t0 | Add License Manager tables (license, license_lead); merges the prior demo / system-setting heads. |
p6q7r8s9t0u1 | Add the license_request table (managed device → approve → download flow). |
q7r8s9t0u1v2 | reader.mac_address MACADDR → VARCHAR(128); add QDATDROID to readermodelenum. |
Patch release. Hardens the managed license-request flow and adds new-vs-renewal context to the console. No schema migration was required for the console enrichment; the audit-enum migration r8s9t0u1v2w3 lands as the head of this chain.
A device whose previous request had already resolved (approved *or* revoked) could re-POST /request-license and receive a lodged 200 while no new pending row was actually persisted — so a revoked device's renewal never reappeared in the console. The endpoint now lodges a fresh pending request for any non-pending latest state, making renewals reliable.
Each pending request in the console now shows a Type badge (*New* when the device has no prior licence, *Renewal* otherwise), the device's most-recent prior licence (key + status — typically the revoked one prompting the re-request), and the request timestamp. This is backed by three derived (not stored) fields on the admin request view — is_new, previous_license_key, previous_license_status — computed by looking up the device's prior licence. No schema migration was required.
| Revision | Description |
|---|---|
r8s9t0u1v2w3 | Add LICENSE_CREATED / LICENSE_REVOKED / LICENSE_DELETED to autheventtypeenum. |
Both production clusters are at head r8s9t0u1v2w3. alembic upgrade head runs the whole chain.
Patch release. Fixes a managed request-license that was silently dropped when the device already held a trial, and makes approval replace the trial. No schema migration.
request_license() opened with an auto-issue short-circuit: if the device already held an active license, it handed that license straight back with 200 and returned *before* lodging a LicenseRequest. Because _is_active() counts an unexpired trial as active, a device on a trial that asked for a full (managed) license got its trial echoed back and no pending request was ever persisted — so the request never appeared in the /licenses console.
The short-circuit now excludes trials (… and not active.is_trial): a trial holder asking for a full license falls through and always lodges a pending request. A device with a real (non-trial) active license still re-downloads it without creating a duplicate request. A license request is therefore never silently dropped.
When a managed request is approved, the device's prior trial license(s) are moved to expired as the full license is minted, so the approved license is the single authoritative license for that device_id (the bind key — get_by_device_id returns one row). A new LicenseRepository.list_by_device_id() backs this.
app/services/license.py — request_license() trial exclusion; approve_request() trial expiry.app/repositories/license.py — new list_by_device_id().app/tests/services/test_license_service.py — regression coverage (request always logged; idempotent refresh; trial expired on approval).Patch release. Lets the combined provisioning wizard re-use a reader that already owns the device id instead of failing. No schema migration.
The wizard's step 2 creates the RFID reader with the Android Device ID as a quasi-MAC. If a reader already owned that device id — e.g. the device had a prior trial-provisioned reader — POST /api/v1/readers/ returned 409 MAC address already in use by reader '<name>' …, and provisioning dead-ended.
POST /api/v1/readers/ gains a `rebind` query flag. When rebind=true and a reader already owns the supplied MAC / device id, the endpoint updates that existing reader in place — moves it to the target organization and refreshes its fields (device_model = QDATDROID, name, role) — and returns it, rather than raising 409 or creating a duplicate. The reader is never deleted; the same MQTT identity (reader.id) is preserved, and the wizard regenerates its MQTT credentials for the combined QR. Non-superusers may only re-bind a reader they can already see (else 403).
In the console the wizard surfaces a confirmation on the conflict: re-bind the existing reader (moving it to the selected org, setting QDATDROID, and regenerating its MQTT credentials) or cancel.
app/api/routes/reader.py — rebind flag on create_reader; in-place re-bind path.app/services/reader.py — public get_by_mac_address().app/tests/api/routes/test_reader_mac_uniqueness.py — rebind=true reuses the existing reader (no duplicate; org moved).frontend/src/routes/_layout/reader-form.tsx — wizard confirmation + rebind=true retry.Patch release, follow-up to 2.2.3. No schema migration.
A re-bound reader usually already holds active MQTT credentials, so the wizard's follow-up POST /api/v1/readers/{id}/mqtt-credentials (which generates and refuses to overwrite) returned 409 Reader already has active MQTT credentials. The wizard now calls `POST /api/v1/readers/{id}/mqtt-credentials/regenerate` on the re-bind path — it revokes the old dynsec client then issues a fresh username/password + topic UUID (and is a no-op revoke when the reader has none). A freshly created reader still uses plain generate, which correctly returns 201. Frontend-only (reader-form.tsx).
app/models/license.py — new ORM tables and all request/response schemas (TrialRequest/Response, Validate*, Revoke*, Sync*, License*, MqttCredentials, DeviceProvisioning, LicenseRequest*).app/models/reader.py — mac_address type change; QDATDROID enum value.app/models/auth_event.py — three new AuthEventTypeEnum values.app/repositories/license.py — data access for licences, leads, requests.app/services/license.py — business logic; list_requests_read() enrichment; the re-queue fix.app/api/routes/license.py — public + admin routers; trial MQTT provisioning; audit logging.app/alembic/versions/ — the four License Manager migrations above.Can't find the answer here? Email our support team — we reply fast.