Add per-meter MQTT value scale + expose device topic/scale in Meters UI

The water-softener totaliser publishes litres but the water category is
m3 — without a conversion factor its readings would land in the DB
1000x too high. Also, mqtt_topic was only settable via direct SQL since
the Meters form never exposed it.
This commit is contained in:
jtricerolph 2026-07-28 17:08:22 +00:00
parent 1243b73a04
commit f5b0b01ff1
5 changed files with 42 additions and 12 deletions

View file

@ -47,6 +47,12 @@ export async function initDb() {
ALTER TABLE meters ADD COLUMN IF NOT EXISTS mqtt_topic TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS meters_mqtt_topic_uidx ON meters (mqtt_topic) WHERE mqtt_topic IS NOT NULL;
-- multiplied onto a raw device-published value before it's stored, so a
-- device publishing in a different unit than the category's billed unit
-- (e.g. the water-softener totaliser publishes litres, category is m3)
-- lands correctly with no per-category special-casing. 1 = no conversion.
ALTER TABLE meters ADD COLUMN IF NOT EXISTS mqtt_value_scale NUMERIC(12,6) NOT NULL DEFAULT 1;
-- gas meters read raw volume off the dial (m3 on newer metric meters, ft3 on
-- older imperial ones like ours), never kWh directly these two columns let
-- cost-calc.js convert a volume reading to billed kWh using the same

View file

@ -14,7 +14,7 @@ const BROKER_RECONNECT_BACKOFF_MS = [30, 60, 120, 300, 600, 1800].map(s => s * 1
let client = null
let connecting = false
let reconnectAttempt = 0
let topicMap = new Map() // mqtt_topic -> meter_id
let topicMap = new Map() // mqtt_topic -> { meterId, scale }
let subscribedTopics = new Set()
async function getCredentials() {
@ -31,9 +31,9 @@ async function getCredentials() {
export async function refreshTopicMap() {
const { rows } = await pool.query(
`SELECT id, mqtt_topic FROM meters WHERE mqtt_topic IS NOT NULL AND active = TRUE`
`SELECT id, mqtt_topic, mqtt_value_scale FROM meters WHERE mqtt_topic IS NOT NULL AND active = TRUE`
)
topicMap = new Map(rows.map(r => [r.mqtt_topic, r.id]))
topicMap = new Map(rows.map(r => [r.mqtt_topic, { meterId: r.id, scale: Number(r.mqtt_value_scale) }]))
if (client?.connected) subscribeToKnownTopics()
}
@ -47,11 +47,12 @@ function subscribeToKnownTopics() {
}
function handleMessage(topic, message) {
const meterId = topicMap.get(topic)
if (!meterId) return
const value = Number(message.toString().trim())
if (!Number.isFinite(value)) return
pool.query(`INSERT INTO meter_telemetry_raw (meter_id, value) VALUES ($1, $2)`, [meterId, value])
const entry = topicMap.get(topic)
if (!entry) return
const raw = Number(message.toString().trim())
if (!Number.isFinite(raw)) return
const value = raw * entry.scale
pool.query(`INSERT INTO meter_telemetry_raw (meter_id, value) VALUES ($1, $2)`, [entry.meterId, value])
.catch(err => console.error('[mqtt] telemetry insert failed:', err.message))
}

View file

@ -93,11 +93,12 @@ export async function meterRoutes(app, opts) {
if (!b.name || !b.category_id) return reply.status(400).send({ error: 'name and category_id required' })
const { rows } = await pool.query(
`INSERT INTO meters (category_id, parent_meter_id, name, location, serial_number, install_date, notes, mqtt_topic,
gas_volume_unit, gas_calorific_value)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
mqtt_value_scale, gas_volume_unit, gas_calorific_value)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
[
b.category_id, b.parent_meter_id || null, b.name, b.location || null, b.serial_number || null,
b.install_date || null, b.notes || null, b.mqtt_topic || null,
b.mqtt_value_scale || 1,
b.gas_volume_unit || null, b.gas_calorific_value || null,
]
)
@ -116,8 +117,8 @@ export async function meterRoutes(app, opts) {
const { rows } = await pool.query(
`UPDATE meters SET category_id = $1, parent_meter_id = $2, name = $3, location = $4,
serial_number = $5, install_date = $6, notes = $7, active = $8, mqtt_topic = $9,
gas_volume_unit = $10, gas_calorific_value = $11
WHERE id = $12 RETURNING *`,
mqtt_value_scale = $10, gas_volume_unit = $11, gas_calorific_value = $12
WHERE id = $13 RETURNING *`,
[
b.category_id ?? m.category_id,
b.parent_meter_id !== undefined ? b.parent_meter_id : m.parent_meter_id,
@ -128,6 +129,7 @@ export async function meterRoutes(app, opts) {
b.notes !== undefined ? b.notes : m.notes,
b.active ?? m.active,
b.mqtt_topic !== undefined ? b.mqtt_topic : m.mqtt_topic,
b.mqtt_value_scale !== undefined ? b.mqtt_value_scale : m.mqtt_value_scale,
b.gas_volume_unit !== undefined ? b.gas_volume_unit : m.gas_volume_unit,
b.gas_calorific_value !== undefined ? b.gas_calorific_value : m.gas_calorific_value,
req.params.id,