Monitoring and valve control for TagMeter ultrasonic water meters via the tagmeter.com SMP REST API. Frappe v16.
The vendor's Smart Meter Platform is the network server and the control plane. This app does not speak LoRaWAN, does not decode wire frames, and does not talk to ChirpStack — it is an API integration with a state machine. The SMP is Upande's own integration account; customers only ever see this Frappe instance.
V1 — registry and telemetry (done).
vendor/— Frappe-free SMP client: auth, single-flight token, error taxonomy, response parsing, status-byte decode.Water Meter— registry plus denormalised current state, includingonline.Meter Reading— one immutable record per SMP reading, deduped.sync.py— the polling sweep.setup/import_meters.py— seed the fleet, and audit it against the SMP.
V2 — valve control (done).
Meter Command— one document per request, with its delivery history.valve_desiredvsvalve_reportedonWater Meter. Two fields on purpose: when they disagree, either a command is in flight or the meter is not doing what was asked, and one field would have to lie about one of those.valve.py—set_valve, confirmation from readings, watchdog, reconciliation report.- A valve toggle on the Water Meter form, shown only for Quinto Prepaid
meters. The switch reflects
valve_reported— what the meter actually said — never the request. While a command is in flight it shows the requested position but is disabled and labelled pending, because a switch that snapped to the requested position would imply the valve had moved when the downlink may be hours away.
Prepaid recharge is still deliberately absent. recharge_meter and
savewebhook need the tariff and credit-ledger model that does not exist yet,
and issuing credit with nowhere to account for it would be worse than not
issuing it. vendor/client.py refuses both endpoints structurally.
"Operation success!" means the SMP put a downlink in its queue. It does not
mean the valve moved. A command is Confirmed only when a reading taken
after the command was sent shows the requested state.
That cannot distinguish "the command worked" from "the valve was already in
that state" — nothing in the available data can — which is why the field is
confirming_reading rather than anything implying causation.
A reading showing the opposite state is not treated as failure: on Class B the downlink may simply not have arrived yet. Failure comes from expiry, after 26 hours by default (comfortably clearing one 12-hour AMR cycle; the one measured round trip took about 11 hours).
The watchdog re-sends only commands the SMP never accepted. If it already
answered "Operation success!", the downlink is in their queue and sending
again would stack a duplicate.
Only the 10 Quinto Prepaid meters have a valve; the 90 AMR meters are read-only devices, so the form shows no valve buttons for them.
More restrictive still: a command only reaches a meter whose LoRaWAN session is on the vendor's network server, and the fleet is split across two servers per device with nothing managing the split.
This moves. Measured on 2026-09-09, hours apart:
| Time | Quintos live on the SMP |
|---|---|
| ~13:00Z | 1 (…0871 only; the rest 651–675 h stale) |
| ~16:00Z | 8 (only …0868 and …0873 still stale) |
So meters are actively re-joining the vendor's network server. Check
online on the Quinto meters before assuming a command can land, rather than
trusting any number written down here.
/tagmeter is a full-page fleet dashboard, served by www/tagmeter.py +
www/tagmeter.html. It follows the Upande ufd-modern design system used by
the other dashboards — same ink palette, surfaces, card and KPI treatments —
with a water-blue accent in place of the scouting page's trap gold. The
severity scale is deliberately unchanged so a red badge means the same thing
everywhere.
Seven views: Fleet, Register, Meters, Valves, Flow, Graph, Signal.
Register is where meters get the names people actually use — a house, a tenant, a standpipe — plus a zone and a site, edited inline and saved in one batch. Search and filter by name, serial, zone or site; a bar chart above counts meters per zone, so an unassigned block is visible at a glance.
Naming sets a label; it does not rename the record.
Water Meteris auto-named frommeter_sn, and that serial is the SMP'smeterIDand the key every reading and command joins on. Renaming the document would break the vendor mapping silently and the next sweep would create a second meter. Someter_labelsits beside the serial: the dashboard shows the name, keeps the serial visible underneath, and falls back to the serial wherever a meter has no name yet. The doctype'stitle_fieldandsearch_fieldsfollow the label, so desk search and link fields find a meter by house number too.
A three-way switch in the top bar chooses how a meter is identified, kept in
localStorage (a per-reader display preference, so it never becomes a doctype
field — the cost is that it does not follow you between browsers). Both is
the default: someone new to the fleet has to learn which name maps to which
serial before the other modes help.
The switch governs the surfaces you scan — tables, pickers, fleet tooltips. Three places override it on purpose:
| Surface | Always shows | Why |
|---|---|---|
| Detail panel | Both | It is where you go to get a serial for the vendor console |
| Register | Both | They are separate columns; that is the view's job |
| Valve confirmation | The serial | Safety — see below |
meter_label is deliberately not unique. Two blocks can each hold a
"Standpipe 4", and forcing uniqueness would turn naming into a fight with a
validator. The cost is that a name alone can be ambiguous, which is harmless
while browsing and dangerous when actuating: closing the wrong customer's water
is the worst thing this app can do. So the valve dialog always reads
House 12 (68750000076973) whatever the display mode, and a test asserts that
in all three.
Any visible serial is click-to-copy, since the usual reason to reveal one is to paste it into the vendor console or a support ticket.
A batch save applies each row independently — one bad row is reported and the rest still land, because refusing the whole batch would throw away an operator's typing.
Graph is a full-bleed interactive line chart — the sidebar drops away, the top bar stays, and the plot takes the viewport. Real X and Y axes with nice ticks, a measure picker (hourly rate, consumption, cumulative, balance, temperature, signal), and a date range.
It is drawn in real pixels sized to its container rather than a scaled
viewBox, so tick text stays 11px at any viewport width, and it redraws
through a ResizeObserver. Interaction: a crosshair snaps to the nearest point
by X, drag across the plot to zoom, double-click or Escape to clear,
arrow/Home/End move the cursor from the keyboard, and every cursor move is
announced through an aria-live region. Point markers appear only on series of
24 points or fewer, at 8px — denser than that they merge into the line, and the
crosshair still finds any point.
Flow takes a meter and a date range and shows daily / weekly / monthly
volume as stat tiles, the meter's own hourly rate as an area chart, and
consumption per reading as bars. Both charts carry a crosshair and tooltip.
Period totals sum consumption_m3 rather than differencing two lifetime
counters, so a meter replacement or counter reset cannot produce a negative.
The hourly series is the SMP's rolling 24-hour window, where delta is that
hour's volume — already differenced by the meter.
Valve control is on the dashboard, in the fleet detail panel and the Flow
view. It calls valve.set_valve, the same entry point the desk form uses, so
the in-flight guard, the profile check and the confirmation-by-reading rule
apply identically. The buttons disable while a command is in flight and
explain why.
Chart colours were contrast-checked rather than eyeballed: #2b6ca3 measures
5.56:1 against the card surface and carries every stroke and bar, #1b4a73
(9.24:1) marks the hovered point, and #6ea9d8 — 2.52:1, too weak for a mark —
appears only inside the gradient fill beneath a darker line.
Two deliberate choices worth keeping:
- The hero is a 100-cell recency grid, not a consumption chart.
TotalCounteris0.000on every meter, so a volume trend would be a straight line at zero dressed up as insight. What the data actually contains is which meters are alive and when each last spoke, so that is what the page leads with. One cell per meter, coloured by how long ago it reported, outlined if it has a valve, click for full detail. - Empty means empty. Where there is no data the page says so in words — the consumption panel explains that the fleet is installed but not in service — rather than rendering a chart of nothing.
All figures come from Water Meter / Meter Reading / Meter Command at
request time; there is no separate cache to go stale.
Every configurable value resolves in three steps, in
upande_tagmeter/settings.py:
sites/<site>/site_config.json— authoritative. A site configured this way behaves exactly as it did before the settings page existed.- TagMeter Settings (
/app/tagmeter-settings) — the desk page, for sites where step 1 is unreachable. On hosted Frappe an operator can hold System Manager and still have no way to write a server file, which otherwise leaves the app permanently unconfigurable by the person actually running it. The password is aPasswordfield: encrypted at rest, never sent to the browser. - The built-in default, which is what the code used all along.
site_config is kept on top so an operator can never quietly override something an administrator pinned in a file only root can write.
{
"tagmeter_api_url": "https://tagmeter.com/restapi/v2_0/002/api",
"tagmeter_api_user": "<service account>",
"tagmeter_api_password": "<secret>"
}002 in the URL is the tenant code, so a second tenant is a config change.
Everything else is optional. The settings page carries the same defaults and the reasoning behind each one, shows what the multipliers work out to in hours, and lists which layer every value is actually coming from.
| site_config key | Settings field | Default | Purpose |
|---|---|---|---|
tagmeter_console_url |
Console API URL | http://iotcloud.tagmeter.com:8099/prod-api |
Undocumented console API, enrichment only |
tagmeter_min_interval |
Minimum seconds between calls | 0.25 |
Pacing against an undocumented rate limit |
tagmeter_request_timeout |
Request timeout | 30 |
Seconds before a call is abandoned |
tagmeter_max_attempts |
Attempts per call | 3 |
Counts the first try; transport faults only |
tagmeter_retry_backoff |
Retry backoff | 1.0 |
First backoff in seconds, then doubling |
tagmeter_meter_timezone |
Meter timezone | UTC |
Zone of meter TimeStamp fields |
tagmeter_offline_after_hours |
Offline after | 30 |
Staleness window for online |
tagmeter_expected_cycle_hours |
Expected reporting cycle | 24 |
One cycle, fleet-wide |
tagmeter_late_multiplier |
Late after (× cycle) | 1.25 |
One missed report plus grace |
tagmeter_silent_multiplier |
Silent after (× cycle) | 3 |
Three missed reports |
gateway_stale_after_hours |
Gateway stale after | 6 |
Heartbeat age that counts as down |
tagmeter_valve_timeout_minutes |
Command expiry | 10 |
Before a queued command is Expired |
tagmeter_duplicate_window_seconds |
Duplicate window | 30 |
Same-state repeat treated as a double-click |
Zero means "not set", not "zero". Frappe stores an untouched Float or
Int field as 0, so a settings document nobody has ever opened would
otherwise pin the app to a 0-hour reporting cycle, zero attempts per call and a
0-second timeout. None of these values is meaningful at zero, so zero falls
through to the default — in site_config.json too, so the two layers cannot
disagree about what a zero means.
The app needs its own SMP service account. Requesting a token revokes the previous one, so if a human logs into tagmeter.com as the same user, the app's session dies.
bench --site tagmeter.local consolefrom upande_tagmeter import sync
from upande_tagmeter.setup import import_meters
sync.test_connection() # credentials + reachability, touches no meter
import_meters.run() # seed 100 meters (idempotent)
import_meters.verify_against_smp() # confirm each serial; take DevEUI from the SMP
sync.sync_fleet() # poll every meter
sync.sync_fleet(limit=3) # poll a few
sync.poll_meter("68753500170871") # poll one
sync.refresh_online_flags() # recompute online, no API calls
sync.refresh_link_states() # recompute link_state, no API calls
from upande_tagmeter import valve
valve.set_valve("68753500170871", "close") # queue a command
valve.watchdog() # expire / re-send
valve.reconciliation_report() # desired != reportedUnit tests need no bench, no database and no network:
cd apps/upande_tagmeter && ../../env/bin/python -m pytest tests/ -qEach of these is pinned by a test in tests/.
-
Every response is HTTP 200. Failures arrive as
code: 500in the body. Never trust the HTTP status. -
{"meterID": ""}returnscode: 200 "Operation success!"with null data. An empty serial reads as a healthy meter forever, so serials are validated before a request is sent. -
Two separate things kill a token, with different messages:
- Revocation — requesting a token revokes the previous one:
"Invalid Authorization Token or Authorization Token non-existent". Verified. Hence one shared token and single-flight refresh. - Expiry — tokens also lapse on their own after roughly 65 minutes:
"Authorization Token expired, Request New Authorization Token". Measured (minted14:02:33Z, expired by15:08Z).
Both are handled identically, and refresh stays reactive rather than pre-empting the TTL — a timer that minted early would revoke a token other workers are still using, which is the exact failure it would be avoiding.
- Revocation — requesting a token revokes the previous one:
-
get_customer_inforeturns a bare[]on an expired token — HTTP 200, nocodefield at all — whereget_latest_amrreturns a proper{"code": 401}. A fourth response shape. The client classifies an empty list as unauthorized so the refresh still fires; without that, every call to that endpoint would fail for an hour at a time. -
Error messages lie. Omitting
meterIDyields"Failed checking Authorization Token"with a perfectly valid token. Never branch on message text;code: 500is disambiguated by validating our own input instead. -
No bulk read. One
meterIDper call; an array is rejected. 100 meters is 100 requests, ~1–3 minutes per sweep. -
Their edge 403s the default
python-requestsUser-Agent with an HTML "Request forbidden by administrative rules" page, before the API is reached.urllib, a browser string and our own product string all pass. The client always sends an explicitUser-Agent; removing it breaks every call with no useful diagnostic. Raised asBlockedByVendor, because no credential change fixes it. -
HTTP 500 with an empty body appears to mean "no AMR record for this meter". Their code crashes rather than returning an empty result. Evidence: across 100 meters, every serial that answered
200had a reading and not one answered200with no record — theparse_amr→Nonebranch has never fired against the live platform. The failing set also shrinks as meters come online: it was 15 serials, then 13 once68750000076935and68750000076902started reporting. Not rate limiting either — the same serials failed identically at 0.1s, 0.6s and 1.5s request spacing.These surface as
Outcome.SERVER_ERROR, are recorded on the meter, and are not retried: a second identical call cannot make data exist. Only genuine network faults are retried, with exponential backoff. Treatserver_erroras "the SMP holds nothing for this meter yet", and keep theno_amr_recordbranch anyway in case they ever fix it. -
The SMP uses two different timezones in two different fields, and neither string carries an offset. Getting this wrong is a silent two-hour error on every reading, so both were established by measurement:
- Meter
TimeStamp(dataAMRRecord,dataFreezeRecord,dataHourly) is the meter's own clock and reads UTC. Meter68750000076929reported2026-09-09 13:09:35while the wall clock was13:09:11Z— a live uplink 24 seconds "ahead" of UTC. Amsterdam would have put it at ~15:09. statTimefromget_gateway_statusis generated by the SMP and is Dutch local —15:07:12against13:07:39Z. It shifts with European DST, so it goes throughzoneinfo, never a fixed offset.
If the meters are ever re-synced with the protocol's set-date-time command their clocks could move to local time, hence
tagmeter_meter_timezoneis an override rather than a constant. - Meter
-
A meter can be known with no AMR record. Meters emitting only a 1-byte keepalive produce none, so the SMP answers 200 with no record.
-
ValveStatus/AlarmMessagecarry the raw status byte as a prefix."08[Valve open;Low Balance Alarm;]"is ST10x08. We decode the byte rather than match English text, which iscontent-language-dependent. -
A closed valve is
0x01. The TagMeter README writes this row as10=Closed, which is ambiguous about bit order; the vendor decodes its own meters as01. A sibling implementation maps0b01to Transitioning and so reports every closed valve wrongly.tests/test_status.pyguards against reintroducing that.
The SMP is live, not the stale snapshot it first appeared to be. A full sweep of 100 meters took 148s and produced 85 readings.
The fleet is split across two network servers, per device. Each meter is live on exactly one and stale on the other, which is the OTAA join race: both servers see the JoinRequest, but the meter obeys the first JoinAccept it gets.
68750000076921 SMP: 20 Aug (stale) ChirpStack: today 10:21Z -> on ChirpStack
68750000076885 SMP: 20 Aug (stale) ChirpStack: today 10:18Z -> on ChirpStack
68753500170871 SMP: today 13:05Z ChirpStack: 14 Aug -> on the SMP
Nothing is managing that split. It is the single biggest operational risk here: control only works on the server that holds the session.
The two meters recorded as "never reported" are alive. 68750000076929
and 68750000076963 — flagged in the sibling app as never having joined and
under investigation — are both reporting to the SMP, and were among the four
freshest readings in the fleet.
Status bytes across 86 readings, decoded from the ValveStatus/AlarmMessage
prefixes, with zero text disagreements:
| Bytes | Count | Meaning |
|---|---|---|
0006 |
67 | valve open, empty pipe + reverse flow |
0002 |
17 | valve open, empty pipe |
0802 |
2 | valve open, empty pipe + low balance |
TotalCounter is 0.0 fleet-wide and empty-pipe is universal: mounted and
powered, no water through them yet.
online is a plain recency flag: a reading arrived inside 30 hours. It is
preserved exactly as it was, because tiles, filters and callers depend on it.
link_state says why a meter is quiet, which is the part that decides what
you do about it:
| State | Means | Do |
|---|---|---|
Reporting |
fresher than 1.25x the cycle (30h at the default 24h) | nothing |
Late |
past 1.25x but inside 3x the cycle (30–72h at the default) | nothing yet — this is jitter |
Silent |
past 3x the cycle (72h), gateway healthy | send a technician |
Gateway Down |
past 3x the cycle, bound gateway unhealthy | fix the backhaul, ignore the meters |
Never Seen |
no reading, ever | check commissioning |
No Data on SMP |
get_latest_amr 500s — the SMP holds no record |
see the vendor question below |
Late's 1.25x is not arbitrary: it is the same 30h boundary the online flag
already uses, so Late begins exactly where online flips to false, and one
missed report gets no noisier than it already was. That 25% grace absorbs
jitter in the meter's own reporting cycle before anything is escalated.
A gateway is healthy only when it reports online and its heartbeat is
newer than gateway_stale_after_hours (default 6). Both are required:
F04CD5FFFE01CF70 was observed reporting a statTime two days old, and the
web console's gateway list shows the same column value for a live and a dead
gateway. Only the API's online field plus heartbeat age can be trusted.
Health has three states, not two — healthy, unhealthy and unknown —
because "we have no current evidence" is a different claim from "this gateway
is up", and only unhealthy ever produces a Gateway Down verdict. A gateway
is unknown when it is decommissioned, when it has never been polled, when it
has been polled but has not yet produced a heartbeat and less than
gateway_stale_after_hours has passed since that first poll, or when our
own last poll of it has gone stale.
That last case matters most. last_polled_at only advances when a poll
actually completes, so rotated credentials, an unreachable SMP or a wedged
scheduler freeze it — and then every heartbeat on the site ages past the
cutoff. Without the guard the whole fleet would read Gateway Down within six
hours for an outage that is ours, not the network's, which is precisely the
trust-the-wrong-signal failure this feature exists to eliminate.
The one asymmetry: a gateway that has never produced a heartbeat and whose
polling has also stopped is called unhealthy. There is no prior good state to
protect there, and working gateways all carry heartbeats, so that branch cannot
raise the fleet-wide false alarm above.
Alarm flags are never suppressed by link state. They are the last known physical state of the pipe, and a backhaul outage does not make a burst pipe less real.
Config: tagmeter_expected_cycle_hours (24) and gateway_stale_after_hours
(6), in site_config.json or on the settings page. See Setup.
TotalCounter's unit is assumed m³.PrepaidBalanceis confirmed m³ (a recharge of0.001moved a balance of2to2.001), butTotalCounterreads0fleet-wide, so its scale cannot yet be verified. Re-check once water flows.- The status-byte prefix is parsed as hex. Every value observed so far
(
00,01,02,08) is identical in hex and decimal.status_text_mismatchis set when our decode disagrees with the vendor's wording, so a wrong guess becomes visible rather than silent. accountNumber(savewebhook) comes from a namespace the vendor has not explained, and theircustomerInfois null for every Upande meter. V1 does not depend on either.- The SMP's rate limit is undocumented.
valve_control was probed on 68753500170871 on 2026-09-09 (the only Quinto whose session is on the vendor NS; no consumption,
empty pipe, no customer). What came back shaped how V2 is written.
code: 200 does NOT mean success on a write. The only signal is the message:
forceValve: "Xyzzy" -> code=200 "Invalid valve control command" REJECTED
forceValve: "Open" -> code=200 "Operation success!" accepted
So for writes there is no choice but to inspect message — the opposite of the
rule that works for reads. The client therefore whitelists accepted values
and treats anything that is not an explicit success as a rejection. Writes are
also never auto-retried: a transport failure leaves us unable to tell
whether the request reached their queue, so retries live on the Meter Command
document where the attempt count is visible and bounded.
Accepted forceValve values, from 43 candidates tried:
| Value | Result |
|---|---|
Open / open / OPEN |
accepted — case-insensitive |
Close |
accepted |
Reset |
accepted — undocumented, semantics unknown |
| everything else | "Invalid valve control command" |
A third rejection message exists, for the 90 meters that have no valve:
{"code": 200, "message": "Meter ID 68750000076973 has no valve, Valve Control Aborted"}
Measured 2026-09-09 against three AMR-profile meters; a Quinto answered
"Operation success!" in the same run. So the 90/10 split is the vendor's own
view, not an inference from the ChirpStack device profiles — reclassifying a
meter in Frappe would not make it actuable. The AMR meters also report
PrepaidBalance: 0 where the Quintos report 2.000.
Rejected included Prepaid, prepaid, PrePaid, Prepay, Auto,
MeterControlled, Meter Controlled, MeterControl, Control, Credit,
Normal, Default, 44, 44H, 0x44, 55, 99, Force Open, ForceOpen,
On, Off, 1, 0, and the empty string.
Consequence: the protocol's 44H meter-controlled/prepaid mode is not
reachable through valve_control. That mode is what makes a Quinto meter
manage its own valve against its credit balance — the entire reason those ten
meters exist. Either it is set by some other endpoint, or implicitly by
recharge_meter, or it is not exposed at all. This is the single most
important thing to settle with the vendor before V2 is designed.
Update 2026-09-11: "some other endpoint" exists. The vendor's web console
uses a second, undocumented API at iotcloud.tagmeter.com:8099/prod-api, and
its valve write takes a numeric flag:
POST /meter/updateValveStatus {"address": "68753500170871", "valveFlag": 55}
55 is a value forceValve rejects, so the two APIs do not share a command
set, and the mode unreachable there may well be reachable here. The console's
meter detail shows the vocabulary: icCardMeter.valveStatus = "强制开阀"
(forced open valve) beside chargeStatus = "远程充值" (remote recharge).
The numeric values are deliberately not mapped — doing so means sending
real downlinks to live valves. See
docs/superpowers/specs/2026-09-11-console-api-valve-control-notes.md for the
safe procedure, the shared-account risk to settle first, and the questions for
the vendor.
Reset deserves care: it is accepted, undocumented, and nothing in the vendor's
Postman collection mentions it. Do not send it to a live meter until they say
what it does.
Confirmation latency is real. forceValve=Close was enqueued at
13:51:38Z and the meter had not reported at all for the next 70 minutes — its
previous uplink was 13:05:35Z. The one previously measured round trip, the
19 Aug recharge_meter, took about 11 hours. Any UI must show "in flight"
honestly rather than implying a valve moved.
-
What values does
forceValveaccept? If the44Hmeter-controlled mode is unreachable, autonomous prepaid operation is impossible via the API — which is the entire purpose of the 10 Quinto meters. Highest-risk unknown. -
Can the SMP push uplinks to a webhook? That would remove the polling sweep and give near-real-time telemetry.
-
Is there a rate limit?
-
Does a human logging into the web console revoke the app's token?
-
get_latest_amrreturns HTTP 500 with an empty body for meters that have no AMR record. Please return200with an empty result instead. Right now a meter with no data is indistinguishable from a server fault, and 13% of the fleet is unreadable through the API. Serials that were failing at 2026-09-09 13:00Z:68750000076884 68750000076886 68750000076889 68750000076899 68750000076907 68750000076912 68750000076915 68750000076927 68750000076934 68750000076943 68750000076954 68750000076967 68750000076973 68753500170869The set shrinks as meters report, which is what points to the cause.