thermopro-ble
v0.1.2
Published
Read temperature and humidity from ThermoPro TP350S / TP35x Bluetooth LE sensors on Linux (BlueZ over D-Bus, no native build, no root)
Maintainers
Readme
thermopro-ble
Read temperature and humidity from ThermoPro TP350S and related TP35x Bluetooth LE sensors on Linux.
Values come straight from the sensor's advertisement broadcasts, so nothing ever connects to the device: no pairing, no native build, no root, and the vendor mobile app keeps working at the same time.
import { readSensors } from 'thermopro-ble'
const readings = await readSensors()
console.log(readings)
// [
// {
// address: 'D8:A7:DD:A2:3B:0B',
// name: 'TP350S (3B0B)',
// rssi: -31,
// temperature: 28.5,
// humidity: 37,
// decoder: 'tp350s-adv',
// source: 'advertisement',
// raw: <Buffer c2 1d 01 25 22 2b 01>,
// at: 2026-08-12T01:13:44.812Z
// }
// ]Contents
- Requirements
- Install
- Quick start
- Choosing an API
readSensors()— read onceAdvertisementMonitor— many sensors, liveSensorWatcher— react only to changesReadingTracker— change detection on its ownGattListener— connected mode- Sessions and shutdown
- Supporting other sensors
- Data types
- Command line
- TypeScript
- Supported hardware
- Troubleshooting
- Full export list
Requirements
| | |
| --- | --- |
| OS | Linux with BlueZ running — the library talks to BlueZ over D-Bus, so macOS and Windows are not supported |
| Node.js | 18 or newer |
| Adapter | any Bluetooth LE adapter, powered on |
| Privileges | none — no root, no setcap, no compilation |
Check your system:
systemctl status bluetooth # should be active
rfkill list bluetooth # should not be blockedInstall
npm install thermopro-bleThe package is ESM-only. In CommonJS, use a dynamic import:
const { readSensors } = await import('thermopro-ble')Quick start
Find your sensor's address first:
npx thermopro-ble scanMAC Name RSSI Packets Manufacturer data (reconstructed) ThermoPro?
----------------- ------------- ---- ------- --------------------------------- ----------
D8:A7:DD:A2:3B:0B TP350S (3B0B) -31 7 C218012722B01 <<<
EA:7B:04:EB:EE:35 TP350S (EE35) -88 2 C2D4002722B01 <<<
Candidates:
D8:A7:DD:A2:3B:0B TP350S (3B0B) (RSSI -31, 7 packets)
advertisement: C2 18 01 27 22 2B 01 -> 28.0 °C 39 %RH [tp350s-adv]Then read it:
import { readSensors } from 'thermopro-ble'
const [reading] = await readSensors({ addresses: ['D8:A7:DD:A2:3B:0B'] })
console.log(`${reading.temperature} °C, ${reading.humidity} %RH`)
// 28.5 °C, 37 %RHChoosing an API
| You want | Use |
| --- | --- |
| the current value, once | readSensors() |
| a continuous stream from one or many sensors | AdvertisementMonitor |
| to act only when something changes | SensorWatcher |
| change detection over your own data | ReadingTracker |
| data from a device that broadcasts nothing | GattListener |
Everything except GattListener is passive: it listens for broadcasts and never
connects, so any number of programs can read the same sensor at once.
Important: connecting locks the sensor
The library has two modes, and they behave very differently.
| | Passive (default) | Connected |
| --- | --- | --- |
| API | readSensors, AdvertisementMonitor, SensorWatcher | GattListener |
| CLI | scan, monitor | explore, listen |
| Locks the device | no | yes |
| Vendor app keeps working | yes | no |
| Several programs at once | yes | no |
⚠️ While a connection is open, nothing else can use the sensor
BLE devices generally accept one connection at a time. As long as
GattListener— or theexplore/listencommand — holds a connection:
- the ThermoPro mobile app cannot connect, and will appear broken
- a second script cannot connect, and fails with
Device or resource busy- the sensor keeps broadcasting, so passive readers are unaffected
The device is released again only when you call
stop(), close the session, or the process exits. A crashed process can leave the connection hanging until BlueZ times it out — if the sensor seems stuck afterwards, check for a leftover process (pgrep -af node) and runbluetoothctl disconnect <MAC>.You almost certainly do not need this. TP350S broadcasts everything it measures, so use the passive API unless you are reverse engineering a device that broadcasts nothing.
⚠️ Never write to Nordic DFU (
fe59)TP350S exposes this service for firmware updates. Writing to its control point (
8ec90001-…) reboots the station into its bootloader, where it stops measuring and stops advertising until it is reset or reflashed.The service is therefore skipped by default.
includeDfuService: trueand--all-servicesremove that guard — only pass them if you know exactly why. Be equally careful with--write, which sends arbitrary bytes to a characteristic you name.
Two more things worth knowing before you build on this:
- Sensors cannot be polled. They broadcast every few seconds on their own
schedule. There is no request/response, so "read right now" does not exist —
readSensors()waits for the next broadcast. - A connection stops discovery. Connecting pauses advertisement scanning on
that D-Bus session, so a
GattListenerand a monitor must not share a session.
readSensors() — read once
Starts listening, takes the newest value per sensor, and resolves.
import { readSensors } from 'thermopro-ble'
const readings = await readSensors({
addresses: ['D8:A7:DD:A2:3B:0B', 'CE:68:B0:0A:D7:3E'],
timeoutMs: 20000
})
for (const { address, name, temperature, humidity, rssi } of readings) {
console.log(`${address} ${name}: ${temperature} °C, ${humidity} %RH (${rssi} dBm)`)
}Options
| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| addresses | string[] \| string | — | devices to read. Omit to collect everything in range. |
| timeoutMs | number | 20000 | upper bound on waiting |
| session | BleSession | — | reuse an existing session |
| decoders | DecoderRegistry | shared default | decoder set to use |
How it decides when to stop
- With
addressesit returns as soon as every listed device has reported — usually a second or two, not the full timeout. - Without
addressesit always waits the fulltimeoutMs, collecting whatever turns up.
A quiet sensor is simply absent
Sensors broadcast every few seconds and cannot be polled on demand, so one that is asleep or out of range produces no entry at all. Never assume one result per requested address:
const wanted = ['D8:A7:DD:A2:3B:0B', 'CE:68:B0:0A:D7:3E']
const readings = await readSensors({ addresses: wanted, timeoutMs: 30000 })
const byAddress = new Map(readings.map(r => [r.address, r]))
for (const address of wanted) {
const reading = byAddress.get(address)
console.log(reading ? `${address}: ${reading.temperature} °C` : `${address}: no response`)
}Scanning everything in range, sorted by signal strength (nearest first):
const all = await readSensors({ timeoutMs: 15000 })
console.log(`${all.length} sensor(s) in range`)AdvertisementMonitor — many sensors, live
One monitor handles any number of sensors at the same time.
import { AdvertisementMonitor } from 'thermopro-ble'
const monitor = new AdvertisementMonitor({
addresses: ['D8:A7:DD:A2:3B:0B', 'CE:68:B0:0A:D7:3E']
})
monitor.on('reading', reading => {
console.log(`${reading.address} ${reading.temperature} °C ${reading.humidity} %RH`)
})
await monitor.start()
// ... later
await monitor.stop()Omit addresses to watch every LE device in range:
const monitor = new AdvertisementMonitor()
monitor.on('device', device => console.log('found', device.address, device.name))
monitor.on('reading', reading => console.log(reading.address, reading.temperature))
await monitor.start()Options
| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| addresses | string[] \| string | all | devices to watch; empty means everything in range |
| session | BleSession | own | reuse a session; the monitor then leaves it open on stop() |
| decoders | DecoderRegistry | shared default | decoder set to use |
| discovery | boolean | true | start LE discovery on start() |
| source | AdvertisementSource | D-Bus | alternative data source, mainly a testing seam |
Events
| Event | Argument | Fires |
| --- | --- | --- |
| reading | SensorReading | a payload decoded into measurements |
| frame | AdvertisementFrame | every decodable payload, understood or not |
| device | DeviceRecord | the first time each device is seen |
| error | Error | a non-fatal problem |
Properties and methods
monitor.addresses // string[] — watched addresses, empty means "all"
monitor.devices // DeviceRegistry — everything seen so far
monitor.session // BleSession | null
monitor.addAddress('EA:7B:04:EB:EE:35') // start watching another device
monitor.removeAddress('EA:7B:04:EB:EE:35') // stop watching one
await monitor.start() // returns the monitor, so calls can chain
await monitor.stop()addAddress and removeAddress take effect immediately, including while the
monitor is running. Removing the last address widens the monitor back to
every device in range, so prefer stop() when you mean "stop".
The monitor does not deduplicate
Every advertisement is reported, including identical repeats. That is
deliberate — it keeps raw data available for logging and reverse engineering.
If you only care about changes, use SensorWatcher
rather than filtering by hand.
Raw frames
frame fires for payloads no decoder understands, which is how you inspect an
unknown device:
import { AdvertisementMonitor, toHex } from 'thermopro-ble'
const monitor = new AdvertisementMonitor()
monitor.on('frame', frame => {
console.log(`${frame.address} ${frame.kind} ${toHex(frame.data)}`)
// D8:A7:DD:A2:3B:0B manufacturer C2 1D 01 25 22 2B 01
})
await monitor.start()Errors are dropped when nobody listens
Node crashes the process on an unhandled 'error' event, which would be a rude
thing for a library to do inside someone else's application. This package emits
'error' only when you have subscribed; otherwise the error is discarded.
Subscribe if you want to see them:
monitor.on('error', error => console.error('monitor:', error.message))SensorWatcher — react only to changes
Watches a set of sensors and fires only when a measurement actually moves.
import { watchSensors } from 'thermopro-ble'
const watcher = await watchSensors({
sensors: {
living: 'D8:A7:DD:A2:3B:0B',
garden: 'CE:68:B0:0A:D7:3E'
},
thresholds: { temperature: 0.1, humidity: 1 },
onChange ({ id, changed, reading, previous, initial }) {
if (initial) {
console.log(`${id}: starting at ${reading.temperature} °C`)
return
}
for (const field of changed) {
console.log(`${id}: ${field} ${previous[field]} -> ${reading[field]}`)
}
}
})living: starting at 28.2 °C
garden: starting at 21.1 °C
living: temperature 28.2 -> 28.3
garden: humidity 39 -> 38watchSensors(options) builds the watcher and starts it. Use the class directly
when you want to attach listeners before starting:
import { SensorWatcher } from 'thermopro-ble'
const watcher = new SensorWatcher({ sensors: { living: 'D8:A7:DD:A2:3B:0B' } })
watcher.on('change', change => console.log(change.id, change.changed))
await watcher.start()Naming your sensors
Three shapes are accepted, so the rest of your code never has to deal in MAC addresses:
sensors: { living: 'D8:A7:DD:A2:3B:0B', garden: 'CE:68:B0:0A:D7:3E' } // your labels
sensors: ['D8:A7:DD:A2:3B:0B', 'CE:68:B0:0A:D7:3E'] // address as label
sensors: [{ id: 'living', address: 'D8:A7:DD:A2:3B:0B' }] // explicit pairsOptions
| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| sensors | see above | required | the set to watch |
| thresholds | { [field]: number } | {} | dead band per field |
| reportInitial | boolean | true | emit each sensor's first reading, marked initial: true |
| onChange | function | — | shorthand for .on('change', …) |
| session | BleSession | own | reuse a session |
| decoders | DecoderRegistry | shared default | decoder set to use |
Dead bands
thresholds sets the minimum change before a field counts. Fields you leave out
react to any difference at all.
thresholds: { temperature: 0.2, humidity: 2 }The comparison runs against the last value you were told about, not the last value received. That matters: a sensor drifting 0.1 °C at a time would otherwise drag the baseline along with it and never trigger, however far it wandered. With a 0.5 °C dead band:
20.0 °C -> change (initial)
20.1 °C -> silent
20.2 °C -> silent
20.4 °C -> silent
20.5 °C -> change, previous 20.0, delta +0.5To stay completely silent until something moves, turn the initial reading off:
const watcher = await watchSensors({
sensors: ['D8:A7:DD:A2:3B:0B'],
reportInitial: false,
onChange: ({ reading }) => sendAlert(reading)
})The change event
watcher.on('change', change => {
change.id // 'living' — your label
change.address // 'D8:A7:DD:A2:3B:0B'
change.changed // ['temperature'] — which fields moved
change.deltas // { temperature: 0.3 } — signed, empty on the initial reading
change.initial // true only for the first reading of this sensor
change.reading // the new SensorReading
change.previous // the last reported SensorReading, null when initial
})Reacting to one field only:
watcher.on('change', ({ id, changed, reading }) => {
if (!changed.includes('temperature')) return
if (reading.temperature > 25) console.log(`${id} is too warm`)
})Other events and methods
watcher.on('reading', reading => …) // every reading, changed or not; carries .id
watcher.on('error', error => …) // dropped when nothing is subscribed
watcher.sensors // [{ id, address }] — the watched set
watcher.monitor // the underlying AdvertisementMonitor
watcher.snapshot() // current state of every sensor
await watcher.stop()snapshot() includes sensors that have never reported, so it works as a status
board:
console.table(watcher.snapshot().map(({ id, address, reading, lastSeen }) => ({
id,
address,
temperature: reading?.temperature ?? '—',
humidity: reading?.humidity ?? '—',
lastSeen: lastSeen?.toLocaleTimeString() ?? 'never'
})))Detecting a sensor that went quiet — snapshot() gives you lastSeen, so a
plain interval is all it takes:
setInterval(() => {
const cutoff = Date.now() - 10 * 60_000
for (const { id, lastSeen } of watcher.snapshot()) {
if (!lastSeen || lastSeen.getTime() < cutoff) console.warn(`${id} silent for 10 min`)
}
}, 60_000)ReadingTracker — change detection on its own
The change logic used by SensorWatcher, usable independently — for readings
from a database, another transport, or a sensor this library does not handle.
import { ReadingTracker } from 'thermopro-ble'
const tracker = new ReadingTracker({
thresholds: { temperature: 0.5 },
reportInitial: false
})
tracker.update('boiler', { temperature: 60.0 }) // null (initial, suppressed)
tracker.update('boiler', { temperature: 60.2 }) // null (below the dead band)
const change = tracker.update('boiler', { temperature: 60.6 })
// { changed: ['temperature'], deltas: { temperature: 0.6 },
// current: {…}, previous: {…}, initial: false }| Method | Returns |
| --- | --- |
| update(key, reading) | a change, or null when nothing moved enough |
| get(key) | the newest reading seen for key |
| snapshot() | Map of every key to its newest reading |
| reset(key?) | forget one key, or all of them |
Keys are arbitrary strings, so any identifier works. Options are thresholds,
reportInitial, and fields (which measurements to compare — defaults to
temperature and humidity).
GattListener — connected mode
TP350S broadcasts everything it measures, so you almost certainly do not need this. It exists for devices that only expose data over a connection.
⚠️ This locks the sensor
While the listener is running, the vendor app and any other program are shut out — see the warning above. Always
stop()when you are done, and give it its own session.
import { GattListener } from 'thermopro-ble'
const listener = new GattListener({
address: 'D8:A7:DD:A2:3B:0B',
onProgress: console.log
})
listener.on('subscribed', ({ uuid }) => console.log('subscribed to', uuid))
listener.on('reading', reading => console.log(reading.temperature, reading.humidity))
listener.on('notification', ({ uuid, data }) => console.log(uuid, data))
await listener.start()
// ... later
await listener.stop()Options
| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| address | string | required | device to connect to |
| uuid | string | all | subscribe to this characteristic only |
| includeDfuService | boolean | false | also subscribe to Nordic DFU — see the warning below |
| connectTimeoutMs | number | 30000 | how long to wait for the device |
| session | BleSession | own | reuse a session |
| onProgress | function | none | connection progress text |
Wake-up writes
Some devices stay silent until a command is written to them:
await listener.start()
await listener.write('ffe2', Buffer.from([0x01, 0x00]))
console.log(listener.subscribedUuids) // what it is listening to
console.log(listener.writableUuids) // what accepts writesNordic DFU (
fe59) is dangerous. TP350S exposes it for firmware updates, and writing to its control point reboots the station into its bootloader. The service is skipped unless you passincludeDfuService: true.
stop() unsubscribes and disconnects, and closes the session when the listener
opened it.
Sessions and shutdown
Each monitor opens its own D-Bus session and closes it on stop(). That is
usually what you want, and you can ignore sessions entirely.
Sharing one session
Pass a BleSession to share one D-Bus connection between several consumers. A
session you supply is never closed by a monitor — closing it stays your job:
import { BleSession, AdvertisementMonitor, SensorWatcher } from 'thermopro-ble'
const session = await BleSession.open()
const monitor = new AdvertisementMonitor({ addresses: ['D8:A7:DD:A2:3B:0B'], session })
const watcher = new SensorWatcher({ sensors: ['CE:68:B0:0A:D7:3E'], session })
await monitor.start()
await watcher.start()
// ... later
await monitor.stop()
await watcher.stop()
await session.close() // yours to closeDo not share a session between a monitor and a
GattListener. Connecting stops discovery — BlueZ connects far more reliably when it is not scanning — which also stops advertisements reaching the monitor. Give the listener its own session, or callstartDiscovery(session)again once it has connected.
Graceful shutdown
untilInterrupted() resolves on SIGINT/SIGTERM, or after a timeout:
import { AdvertisementMonitor, untilInterrupted } from 'thermopro-ble'
const monitor = new AdvertisementMonitor()
monitor.on('reading', console.log)
await monitor.start()
await untilInterrupted() // Ctrl+C, or untilInterrupted(60000) for a minute
await monitor.stop()withSession() handles setup and teardown for lower-level work:
import { withSession, connectDevice } from 'thermopro-ble'
await withSession(async session => {
const { device } = await connectDevice(session, 'D8:A7:DD:A2:3B:0B')
// the session closes automatically, however this block ends
})Supporting other sensors
Decoding is extensible: register a layout instead of patching the library.
import { registerDecoder } from 'thermopro-ble'
registerDecoder({
name: 'my-sensor',
sources: ['advertisement'],
note: 'C3 | int16 LE @1 /10 °C | u8 @3 %RH',
decode (payload) {
if (payload.length < 4 || payload[0] !== 0xc3) return null
return {
temperature: payload.readInt16LE(1) / 10,
humidity: payload.readUInt8(3)
}
}
})Every monitor picks it up immediately, and reading.decoder reports
'my-sensor' for frames it claims.
Decoder fields
| Field | Required | Meaning |
| --- | --- | --- |
| name | yes | identifier reported as reading.decoder |
| decode(payload) | yes | return measurements, or null when the layout does not fit |
| sources | no | limit to 'advertisement' and/or 'gatt'; omit for both |
| matches(context) | no | extra guard, e.g. one characteristic only |
| priority | no | higher wins; defaults to 50 — see below |
| note | no | human description, shown by the CLI |
Priority
Decoders are tried highest priority first, and equal priorities keep their registration order. The built-in scale:
| Priority | Used by | |
| --- | --- | --- |
| 100 | verified model layouts | tp350s-adv, tp350s-gatt |
| 90 | standard layouts guarded to one characteristic | sig-temperature |
| 50 | your decoders, by default | |
| 10 | permissive fallbacks | tp35x-adv |
The default is chosen so registration actually works: a decoder you register
outranks the library's permissive guesses — which happily parse almost any four
plausible bytes — but does not displace a layout verified against that exact
model. Set priority explicitly to override either way:
registerDecoder({ name: 'mine', priority: 200, decode }) // beats everythingTwo safeguards mean a loose layout cannot produce nonsense:
- Plausibility. Results outside −40…80 °C or 1…99 %RH are discarded, so a layout that happens to parse an unrelated payload yields nothing.
- Context. Register with
sourcesandmatchesso a decoder only sees the payloads it was written for:
registerDecoder({
name: 'vendor-notify',
sources: ['gatt'],
matches: context => context.uuid === 'ffe1',
decode: payload => ({ temperature: payload.readInt16LE(2) / 100 })
})Working out an unknown layout
scanOffsets() reports every position where a plausible temperature could hide:
import { scanOffsets } from 'thermopro-ble'
const payload = Buffer.from('C2BA002D222B01', 'hex')
console.log(scanOffsets(payload))
// [ { offset: 1, endian: 'LE', divisor: 10, temperature: 18.6, humidity: 45 }, … ]Compare the candidates against the number on the device display to find the real
one. The CLI does this live with --scan:
npx thermopro-ble monitor D8:A7:DD:A2:3B:0B --scanIsolated registries
registerDecoder mutates a registry shared by the whole process. Build your own
when you need isolation, or want to exclude the built-ins:
import { DecoderRegistry, AdvertisementMonitor, builtinDecoders } from 'thermopro-ble'
const decoders = new DecoderRegistry([...builtinDecoders])
decoders.register({ name: 'mine', decode: buf => ({ temperature: buf[0] }) })
const monitor = new AdvertisementMonitor({ addresses: ['D8:A7:…'], decoders })Within one priority level, registration order decides, so strict layouts should still be registered before permissive ones.
Decoding by hand
import { decode, decodeAll } from 'thermopro-ble'
decode(Buffer.from('C2BA002D222B01', 'hex'), { source: 'advertisement' })
// { temperature: 18.6, humidity: 45, decoder: 'tp350s-adv', note: '…' }
decodeAll(payload, { source: 'gatt', uuid: '2a6e' }) // every layout that matchedData types
SensorReading
{
address: 'D8:A7:DD:A2:3B:0B', // uppercase MAC
name: 'TP350S (3B0B)', // null when the device sent none
rssi: -31, // dBm, null when unknown
temperature: 28.5, // °C, undefined when the layout has none
humidity: 37, // %RH, undefined when the layout has none
decoder: 'tp350s-adv', // which layout produced this
source: 'advertisement', // 'advertisement' | 'gatt'
raw: <Buffer c2 1d 01 …>, // the exact bytes decoded
at: 2026-08-12T01:13:44.812Z
}temperature and humidity are undefined rather than null when a layout
does not carry them, so ?? and optional chaining behave as expected.
AdvertisementFrame
{
address: 'D8:A7:DD:A2:3B:0B',
name: 'TP350S (3B0B)',
rssi: -31,
kind: 'manufacturer', // 'manufacturer' | 'service'
uuid: null, // service UUID for service data
companyId: 7618, // 0x1DC2 — see the note below
data: <Buffer c2 1d 01 …>,
at: 2026-08-12T01:13:44.812Z
}data for a manufacturer frame is the reconstructed payload: ThermoPro has
no registered Bluetooth company ID and stores measurement bytes in that field,
so BlueZ mistakes the first two payload bytes for a company ID and hands out a
truncated payload. This library puts the frame back together before decoding,
which is why companyId changes as the temperature does — it is not really a
company ID at all.
DeviceRecord
Everything learned about a device, merged across updates. Available from
monitor.devices:
monitor.devices.get('D8:A7:DD:A2:3B:0B')
// { address, name, rssi, addressType, connected, paired, serviceUuids,
// manufacturerData, serviceData, updates: 7, firstSeen, lastSeen }
monitor.devices.list() // all of them, strongest signal first
monitor.devices.sizeCommand line
The package installs a thermopro-ble binary. It is a debugging tool, not
something your application needs.
# passive — safe to run any time, alongside the vendor app
npx thermopro-ble scan # find devices in range
npx thermopro-ble monitor AA:BB:CC:DD:EE:FF # live values
npx thermopro-ble monitor A1:… B2:… C3:… # several devices at once
npx thermopro-ble monitor --changes # only changed measurements
# ⚠️ these CONNECT and lock the sensor until you press Ctrl+C
npx thermopro-ble explore AA:BB:CC:DD:EE:FF # dump the GATT tree
npx thermopro-ble listen AA:BB:CC:DD:EE:FF # log GATT notifications
exploreandlistenhold the sensor open. The vendor app cannot connect while they run, andlistenruns until you stop it — use--duration Nso it releases the device on its own. See the warning above.
$ npx thermopro-ble monitor D8:A7:DD:A2:3B:0B --changes
01:11:35.029 D8:A7:DD:A2:3B:0B TP350S (3B0B) 28.1 °C 39 %RH (first reading)
01:11:38.251 D8:A7:DD:A2:3B:0B TP350S (3B0B) 28.1 °C 38 %RH (humidity -1 %RH)
01:12:01.331 D8:A7:DD:A2:3B:0B TP350S (3B0B) 28.2 °C 38 %RH (temperature +0.1 °C)| Flag | Commands | Meaning |
| --- | --- | --- |
| --seconds N | scan | how long to scan (default 12) |
| --all | scan, monitor | include unnamed devices / unchanged frames |
| --changes | monitor | only changed measurements, with deltas |
| --threshold N | monitor | dead band in °C for --changes |
| --scan | monitor, listen | list every offset that could hold a temperature |
| --log [FILE] | monitor, listen | append frames as JSONL (default logs/) |
| --timeout N | explore, listen | seconds to wait for the device (default 30) |
| --duration N | listen | stop after N seconds |
| --uuid X | listen | subscribe to one characteristic only |
| --write U=HEX | listen | write a command first, e.g. --write ffe2=0100 |
| --all-services | explore, listen | include Nordic DFU (see the warning above) |
Every command takes --help. Addresses can also come from the
THERMOPRO_ADDRESS environment variable (comma separated). Set DEBUG=1 for
full stack traces.
TypeScript
Declarations ship with the package — no @types install needed.
import {
AdvertisementMonitor,
watchSensors,
type SensorReading,
type SensorChange
} from 'thermopro-ble'
const monitor = new AdvertisementMonitor({ addresses: ['D8:A7:DD:A2:3B:0B'] })
monitor.on('reading', (reading: SensorReading) => {
const celsius: number | undefined = reading.temperature
})
await watchSensors({
sensors: { living: 'D8:A7:DD:A2:3B:0B' },
onChange: (change: SensorChange) => console.log(change.id, change.changed)
})Event payloads are typed, so monitor.on('reading', …) infers its argument
without annotation.
Supported hardware
Verified against four TP350S units, cross-checked between the advertisement and GATT paths and against live temperature changes.
| Model | Status | | --- | --- | | TP350S | verified | | TP357 / TP358 / TP359 | same advertisement layout, expected to work | | others | a matter of registering a decoder |
Battery level is not available. These sensors expose no standard Battery Service and do not put battery state in either frame; see the technical documentation for what was ruled out.
Troubleshooting
Nothing is reported although the sensor is on. Sensors broadcast every few
seconds and cannot be polled. Give it a longer timeoutMs, press a button to
wake it, and move closer — at around −90 dBm one or two packets per half minute
is normal.
No Bluetooth adapter found. systemctl start bluetooth, then
rfkill unblock bluetooth.
The Bluetooth adapter is powered off. rfkill unblock bluetooth, or
bluetoothctl power on.
Scanning finds very little. Another program may be scanning with a BR/EDR
filter. This library always opens its own LE discovery session, but a leftover
process of your own can still interfere — check with pgrep -af node.
Device or resource busy when connecting. Only GattListener connects, and
BLE devices usually accept one connection at a time. Disconnect the vendor app
first, or use the advertisement API, which has no such limit.
Values look wrong for a non-TP350S device. A generic layout probably claimed
the payload. Register a decoder with sources and matches so yours takes
priority, and confirm the layout with --scan.
More detail, including BlueZ behaviour worth knowing, is in the technical documentation.
Full export list
Reading sensors — readSensors, AdvertisementMonitor, SensorWatcher,
watchSensors, ReadingTracker, GattListener
Sessions — BleSession, withSession, untilInterrupted, startDiscovery,
stopDiscovery, AdvertisementSource, DBusAdvertisementSource,
DeviceRegistry
Connecting — connectDevice, listCharacteristics, isNotifiable,
isWritable, NORDIC_DFU_SERVICE
Decoding — decode, decodeAll, registerDecoder, DecoderRegistry,
defaultRegistry, builtinDecoders, DEFAULT_PRIORITY, PRIORITY_MODEL,
PRIORITY_STANDARD, PRIORITY_FALLBACK, scanOffsets, formatMeasurements,
MEASUREMENT_FIELDS, PLAUSIBLE_TEMPERATURE_C, PLAUSIBLE_HUMIDITY_PERCENT,
inRange
BlueZ data — parseManufacturerData, parseServiceData, toDeviceUpdate,
looksLikeThermoPro
Formatting — toHex, fromHex, hexdump, isPrintable, shortenUuid,
describeUuid, labelUuid, normalizeAddress, isMacAddress,
addressFromObjectPath, table, timestamp
License
MIT © Dominik Janák
