npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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)

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

| | | | --- | --- | | 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 blocked

Install

npm install thermopro-ble

The 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 scan
MAC                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 %RH

Choosing 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 the explore / listen command — 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 run bluetoothctl 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: true and --all-services remove 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 GattListener and 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 addresses it returns as soon as every listed device has reported — usually a second or two, not the full timeout.
  • Without addresses it always waits the full timeoutMs, 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 -> 38

watchSensors(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 pairs

Options

| 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.5

To 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 writes

Nordic 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 pass includeDfuService: 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 close

Do 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 call startDiscovery(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 everything

Two 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 sources and matches so 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 --scan

Isolated 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 matched

Data 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.size

Command 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

explore and listen hold the sensor open. The vendor app cannot connect while they run, and listen runs until you stop it — use --duration N so 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 sensorsreadSensors, AdvertisementMonitor, SensorWatcher, watchSensors, ReadingTracker, GattListener

SessionsBleSession, withSession, untilInterrupted, startDiscovery, stopDiscovery, AdvertisementSource, DBusAdvertisementSource, DeviceRegistry

ConnectingconnectDevice, listCharacteristics, isNotifiable, isWritable, NORDIC_DFU_SERVICE

Decodingdecode, 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 dataparseManufacturerData, parseServiceData, toDeviceUpdate, looksLikeThermoPro

FormattingtoHex, fromHex, hexdump, isPrintable, shortenUuid, describeUuid, labelUuid, normalizeAddress, isMacAddress, addressFromObjectPath, table, timestamp

License

MIT © Dominik Janák