sensortap Docs

Reference

sensortap documentation

Every CLI command and flag, the whole Python API, every schema field and enum value, the sensor‑id grammar, the consent and streaming models, the complete error table, and the adapter interface. Every value here is taken from the shipped source, not from an example.

Reading this with an AI? Copy the whole reference as one self-contained markdown file, no external includes.

View raw markdown
Schema version1.0
Adapter interface1.0
PlatformWindows 10 or later. Linux adapters are designed but not built.
LicenseMIT
Repositorygithub.com/BaselAshraf81/sensortap

Overview#

sensortap discovers every sensor a computer can reach and exposes raw readings through one API. One registry loads many independent adapters; no adapter knows about any other. Every sensor, whatever its backend, reports the same shape: a stable id, a kind from a closed vocabulary, a unit, a sampling rate, and whether it is currently reachable.

Nothing in the core branches on the operating system. All platform variance lives inside adapters, so the public surface is identical everywhere.

Install#

$ pip install sensortap

Covers camera, microphone, motion, orientation, light, battery, radio, and touchpad sensors, with no extra downloads.

Hardware-monitor sensors (per-core CPU load, temperatures, voltages, power, clock speeds) need the optional extra, which pulls in a bundled self-contained .NET helper of a few dozen megabytes:

$ pip install "sensortap[hwmon]"

Development install#

$ pip install -e ".[dev]"
$ pytest

Concepts#

TermMeaning
RegistryThe single stateful orchestrator. Loads adapters, runs discovery, de-duplicates, routes reads.
AdapterOne backend plug-in owning one sensor family. Knows nothing about any other adapter.
BackendThe platform API or process an adapter talks to (WinRT, a .NET helper, sysfs).
Helper processA separate child process an adapter may spawn. Used by hwmon_bridge.
Sensor idThe stable, three-segment string identifying one sensor.
KindA member of a closed 24-value vocabulary describing what a sensor measures.
Consent grantAn in-memory, scoped authorization to read one privacy-sensitive sensor.
BlockOne buffered chunk of samples from a buffer-dtype sensor, delivered through a stream.

Sensor ids#

Exactly three dot-separated segments:

<kind>.<source-qualifier>.<instance-qualifier>

temp.hwmon.2aed4545974396bc
accel.winrt.0
radio-signal.win-radio.0
RuleValue
Segment countExactly 3
Allowed charactersa-z, 0-9, -
Segment length1 to 64 characters
Total lengthAt most 200 characters
CaseLowercase only
  • kind is a member of the closed kind vocabulary.
  • source-qualifier identifies the reporting backend, for example hwmon, winrt, win-radio, reference. It is not required to equal the adapter's own adapter_id.
  • instance-qualifier is a stable per-device value. Adapters derive it by hashing a persistent hardware identifier (BLAKE2b, 8-byte digest, 16 lowercase hex characters, unsalted so ids reproduce across runs and machines for the same hardware), or use a run-invariant fallback index when no persistent identifier exists. It is never a bus-probe-order index.
Collisions

If two distinct sensors resolve to the same id, every one of them is kept and a disambiguation suffix (-0, -1, …) is appended to the instance qualifier. Ids are validated for grammar before any lookup, so a malformed id fails without touching an adapter.

CLI#

sensortap [global-options] <command> [command-arguments] [global-options]

Global options are accepted both before and after the subcommand: sensortap --json list and sensortap list --json are equivalent.

Output discipline

Everything destined for stdout is buffered and flushed only after the whole command succeeds, so a command that fails partway never leaks a partial table into a pipe. On failure, only the cause is written, to stderr.

list#

Enumerate every discovered sensor, followed by per-adapter status lines and a summary line. Takes no positional arguments.

$ sensortap list
$ sensortap list --json
$ sensortap list --include-elevated --include-motherboard

read#

Read one sensor once.

$ sensortap read temp.hwmon.2aed4545974396bc
$ sensortap read microphone.winrt.0 --consent microphone.winrt.0
ArgumentRequiredDescription
sensor_idyesThe sensor id to read.

Rejected with exit code 4 for a buffer-dtype sensor; use stream instead. The command is bounded at 10 seconds regardless of adapter behaviour, after which it exits with code 6.

stream#

Stream one sensor continuously, one line per block, flushed as it arrives.

$ sensortap stream microphone.reference.0 \
    --consent microphone.reference.0 --duration 10
ArgumentRequiredDescription
sensor_idyesThe sensor id to stream.

Runs until the duration elapses, the stream ends, or the process is interrupted. Requires the owning adapter to implement streaming; otherwise exits with code 4.

inspect#

Print the full SensorInfo record for one sensor, every field included.

$ sensortap inspect accel.reference.0
$ sensortap inspect accel.reference.0 --json

Exits with code 3 if the id is not in the current enumeration.

doctor#

Run the shipped conformance check against every adapter that loaded on this machine, and report the result. Takes no positional arguments.

$ sensortap doctor
$ sensortap doctor --include-elevated
$ sensortap doctor --json

The test suite can only prove the adapter contract holds for hardware the maintainer owns. doctor moves that same check to your machine, where the hardware actually is. Several shipped defects were structurally present on every machine but only observable on one carrying the relevant sensor, so this is the intended first step for any bug report.

Each adapter is run through the five checks in the conformance check: schema compliance of discovered records, Sensor_Id stability across consecutive discover() calls, reachability of every discovered sensor through read() or open_stream(), adapter-level error behaviour, and the declared read-only obligation.

environment
  sensortap: 0.1.0
  python: 3.12.10
  platform: Windows-10-10.0.19045-SP0
  machine: AMD64
  elevated: True

adapters
  [ok  ] winrt_motion
  [ok  ] winrt_audio
  [FAIL] winrt_light
           adapter-level error behaviour: read() returned a Reading for a
           sensor_id this adapter does not own

9/10 adapters passed  failed: 1

On failure the plain-text output appends a GitHub issue URL with title and body already filled in from the run. The environment block reports version, Python version, platform string, machine architecture and elevation state, and deliberately carries no hostname, no username and no sensor ids, because it is built to be pasted in public.

--json emits:

FieldTypeDescription
environmentobjectsensortap, schema, adapter_interface, python, platform, machine, elevated.
adaptersarrayOne entry per loaded adapter: adapter_id, passed, and checks (each name, passed, detail).
summaryobjectadapters_checked, adapters_passed, adapters_failed.
report_urlstringPresent only when at least one adapter failed; the prefilled issue URL.

Exits 0 when every adapter passes and 7 when any adapter fails. Code 7 is deliberately distinct from the generic 1: "sensortap itself broke" and "sensortap works and found a real contract violation on your hardware" are different outcomes, and CI needs to tell them apart.

Global options#

FlagTypeDefaultApplies toDescription
--jsonbooleanoffall Machine-readable JSON output instead of the text table.
--consent SENSOR_IDstring, repeatablenoneread, stream Grant consent for one privacy-sensitive sensor id. Repeat the flag per sensor. Wildcards and patterns are rejected.
--include-elevatedbooleanoffall Load adapters that declare requires_elevation_optin, and include sensors that require elevation.
--include-motherboardbooleanoffall Opt in to motherboard, Super-IO and embedded-controller sensors. See below.
--discovery-timeout MSinteger5000all Per-round discovery timeout in milliseconds. Must be 100 to 60000 inclusive; outside that range is a usage error and the configured timeout is left unchanged.
--duration SECONDSfloatnonestream Stop streaming after this many seconds. Must be 1 to 86400 inclusive. Validated before streaming starts.

Invoking sensortap with no command, or an unrecognised one, prints usage naming all four commands and exits with code 2.

Admin rights change what the hardware monitor sees#

No error, no listed reason

Running sensortap list unelevated vs. from an elevated (Administrator) terminal can report a genuinely different hwmon_bridge sensor count. CPU MSR temperature/clock reads and storage SMART reads both silently return fewer sensors without admin rights; LibreHardwareMonitor doesn't fail loudly, it just reports less. Confirmed on real hardware: 50 vs. 70 sensors on the same machine, same run parameters, differing only by elevation.

sensortap list surfaces this: when hwmon_bridge loaded and the current process is not elevated, the plain-text output prints a note after the summary line, and --json's summary.elevated field reports the same fact structurally. sensortap never requests elevation itself and never triggers a UAC prompt; re-running from an elevated terminal is the user's call.

The motherboard opt-in#

--include-motherboard enables motherboard, Super-IO and embedded-controller monitoring in the bundled hardware-monitor helper. It typically adds board temperatures, fan tachometers, and extra voltage rails.

Why it is off by default
  1. That access path can reach the Ring 0 kernel driver. sensortap never installs, registers, or starts a kernel driver, so where no such driver is already present these sensors simply do not appear.
  2. Embedded-controller register reads can conflict with a vendor tool or another monitoring application (HWiNFO, OEM fan control) already holding the same registers.
  3. An unrecognised Super-IO chip can return plausible-looking nonsense rather than an obvious failure.

Sensors that appear only under this flag carry hwmon-mb as their source qualifier instead of hwmon, so an id records which capability set produced it.

On a machine with no Ring 0 driver present and no recognised Super-IO chip, the flag is safe and finds nothing extra. Desktop boards with a standard Nuvoton or ITE chip usually have more to report than laptops.

Environment variables#

VariableEnabling valuesDefaultEffect
SENSORTAP_HWMON_MOTHERBOARD 1, true, yes, on (case-insensitive, surrounding whitespace ignored) unset (off) Same as --include-motherboard. The CLI flag sets this variable.
Why an environment variable

The registry instantiates every adapter with no arguments, and the adapter interface has no per-adapter configuration channel: acquisition configuration is deliberately limited to sampling rate and block size, which is a different thing from a load-time capability toggle. Set it before constructing a Registry, since adapters read it at instantiation.

Python API#

A single lazily-created Registry backs the module-level functions. Importing sensortap has no side effects: no adapters load, no setup() runs, and no helper process spawns until one of these is first called. Teardown is registered with atexit and runs exactly once per adapter.

import sensortap

# enumerate, opens nothing, prompts nothing
sensors = sensortap.list_sensors()

# read a sensor that needs no consent
reading = sensortap.read("temp.hwmon.2aed4545974396bc")
print(reading.values, reading.status)

# a privacy-sensitive sensor needs an explicit grant
with sensortap.consent(["microphone.winrt.0"]):
    for block in sensortap.stream("microphone.winrt.0"):
        ...

list_sensors()#

list_sensors(*, kind=None, source=None, id=None) -> list[SensorInfo]

Run discovery across every loaded adapter concurrently and return the de-duplicated, validated, ordered records.

ParameterTypeDefaultDescription
kindstr | NoneNoneExact-match filter on kind.
sourcestr | NoneNoneMatches when the value appears anywhere in a record's source tuple.
idstr | NoneNoneExact-match filter on the sensor id.

Filters are conjunctive and exact; nothing matching returns an empty list. Never opens a device and never triggers an operating-system permission prompt, including for camera and microphone sensors. Ordering is deterministic: by adapter load order, then ascending id within an adapter.

read()#

read(sensor_id: str) -> Reading

Read one sensor once. Order of checks, each of which happens before any adapter is invoked: grammar validation, existence against the most recent enumeration, consent, then dtype. Adapter errors are not swallowed; they propagate unchanged.

Enumerate before you read. "The most recent enumeration" means there has to be one. read() never discovers implicitly — that would hide a concurrent sweep of every adapter, under a multi-second timeout, inside what looks like a single-sensor call. So on a fresh process call list_sensors() first; a bare read() raises UnknownSensorError for an id that genuinely exists. The CLI enumerates for you, which is why sensortap read <id> works as a one-liner and the Python equivalent needs two.

Raises MalformedSensorIdError, UnknownSensorError, ConsentError, or BlockPathRequiredError. A rejected read never consumes a seq value.

stream()#

stream(sensor_id, *, block_size=None, rate_hz=None, buffer_blocks=64) -> Stream
ParameterTypeDefaultDescription
sensor_idstrrequiredThe sensor id to stream.
block_sizeint | NoneNoneSamples per block. Outside the backend's supported range raises BlockSizeOutOfRangeError and allocates nothing.
rate_hzfloat | NoneNoneRequested sampling rate. The applied rate may differ.
buffer_blocksint64Ring buffer depth, 2 to 1024 inclusive. Outside that range, or a non-integer, raises ValueError.

These are the only two acquisition parameters anywhere in the interface. There is no **kwargs, so no additional configuration key can be smuggled through. Raises UnsupportedOperationError if the owning adapter does not implement streaming; that adapter's other sensors remain readable through read().

consent(sensor_ids) -> ConsentGrant

Create a grant covering exactly the named sensor ids. See Consent.

backend_status()#

backend_status() -> list[BackendStatus]

Return the current per-adapter status records. Performs no I/O and no adapter interaction, so it answers immediately even before any enumeration has run.

Registry#

Construct one directly for isolation from the module-level singleton. Separate instances never share consent state.

from sensortap import Registry

registry = Registry(
    discovery_timeout_ms=5000,
    include_elevated=False,
    audit_hook=None,
)
ParameterTypeDefaultDescription
discovery_timeout_msint5000Per-round discovery timeout. Must be 100 to 60000 inclusive; validated before any adapter is touched.
include_elevatedboolFalseLoad adapters declaring requires_elevation_optin.
audit_hookCallable | NoneNoneReceives every consent lifecycle event.

Methods: list_sensors, read, stream, consent, backend_status, and shutdown.

shutdown() calls teardown() on every loaded adapter exactly once, catching and recording rather than re-raising any exception so one failing teardown cannot stop the rest. It is idempotent and also registered with atexit. An adapter whose discover() times out contributes no records for that round; it is not an error and does not raise.

Schema types#

SensorInfo#

Frozen and slotted. Records are shared across threads during concurrent enumeration, so immutability removes any question of a consumer mutating a record the registry still holds.

FieldTypeDescription
schema_versionstrSchema version this record follows, currently "1.0".
idstrThe sensor id.
kindstrA member of the closed kind vocabulary.
dtypeDtypeValue shape discriminator.
unitstr | NoneUnit token, at most 32 characters. None when unitless.
channelstuple[str, ...]Channel names, for example ("x", "y", "z").
shapetuple[int, ...]Value shape. Last dimension varies fastest.
rangetuple[float, float] | NoneMinimum and maximum, when known.
resolutionfloat | NoneSmallest distinguishable increment, when known.
rate_hzRateSpecSampling-rate specification.
deliveryDeliveryHow the backend hands values to the adapter.
derivedboolTrue when computed from more than one physical sensor rather than read directly.
requires_consentboolTrue for privacy-sensitive sensors.
requires_elevationboolTrue when reachable only with elevated privileges.
sourcetuple[str, ...]Every reporting backend, in load order.
vendorstr | NoneVendor string, when reported.
part_numberstr | NonePart number, when reported.
availabilityAvailabilityCurrent availability state.
extraMapping[str, object]Adapter-specific extras. Empty by default.

Reading#

FieldTypeDescription
idstrThe sensor id this reading came from.
t_monofloatMonotonic timestamp. Never decreases for a given sensor.
t_wallfloatWall-clock timestamp, Unix epoch seconds.
valuestuple[float, ...]Flat values matching the declared shape. Empty only when status is unavailable.
seqintPer-sensor sequence number. Distinct across concurrent readers.
statusStatusReading status.

Concurrent readers of one sensor always receive distinct seq values, and t_mono is clamped forward if an adapter ever reports a non-increasing clock.

RateSpec#

FieldTypeDescription
defaultfloat | NoneDefault rate in Hz.
minfloat | NoneSlowest configurable rate in Hz.
maxfloat | NoneFastest configurable rate in Hz.
supportedtuple[float, ...]Discrete configurable rates. Empty means a continuous range between min and max.

A requested rate outside min..max raises RateOutOfRangeError. When supported is non-empty and the request is not a member, the nearest supported rate by absolute distance is used, with exact ties resolving to the lower rate.

Enums#

All four are StrEnum, so they compare and serialize as plain strings, and the value sets are identical across platforms by construction.

Dtype

ValueMeaning
scalarOne value.
vector3Three values on three axes.
matrixA two-dimensional grid, for example a touchpad capacitive image.
bufferA block of samples. Readable only through a stream, never read().

Delivery

ValueMeaning
pushThe backend delivers values as they arrive.
pollThe adapter fetches a value on demand.

Availability

ValueMeaning
presentDiscovered and currently readable.
absentThe sensor class is not exposed by the platform or backend.
unavailableDiscovered, but currently fails to read.
in_use_by_other_appAnother process holds exclusive access.
permission_deniedThe operating system denied access.

Status

ValueMeaning
okA current, valid value.
staleOlder than two sampling intervals.
degradedValid, but at least one earlier block was discarded under backpressure.
unavailableNo value. values is empty.

Vocabularies#

Kind vocabulary#

Closed, 24 members. Adding a kind is a MINOR schema bump; removing or re-meaning one is MAJOR. An adapter needing a kind outside this set must land a one-line vocabulary change alongside its adapter file. The friction is deliberate: an open vocabulary would make cross-platform kind filtering meaningless.

accel              gyro               magn               incline
orientation        hinge-angle        light              proximity
temp               fan                voltage            current
power              clock              load               battery
camera             microphone         touchpad           touchscreen
keystroke-timing   radio-signal       humidity           pressure

Unit vocabulary#

unit is validated as a well-formed token of at most 32 characters, not as a semantic UCUM expression. Adapters shipped with sensortap emit only:

degC   V   A   W   Hz   m/s2   deg/s   uT   lx   deg   %   mW.h   rpm

Fan speed is pinned to rpm once in the schema so no adapter picks its own spelling.

Camera, microphone, and touchpad capacitive-image sensors are privacy-sensitive: their SensorInfo.requires_consent is True.

Safe by default

Enumeration never opens a device or triggers a permission prompt. Reading or streaming one of these requires a grant naming that exact sensor id first, or the read is refused with ConsentError and the device is left closed.

import sensortap

with sensortap.consent(["microphone.winrt.0"]) as grant:
    for block in sensortap.stream("microphone.winrt.0"):
        ...
# grant ended, every device opened under it closed
PropertyBehaviour
StorageProcess memory only. Never written to disk, an environment variable, or any other store.
Initial stateEvery gate starts with zero grants.
ScopePer Registry instance. Separate instances never share grant state.
WildcardsRejected. A *, ?, [, or ] anywhere, a non-string, or an empty set raises InvalidConsentRequestError and creates no grant.
EndingContext exit, explicit revoke(), or the atexit backstop. Every device opened under the grant is closed before returning, even if one close fails.
Idempotencerevoke() is safe to call repeatedly.

ConsentGrant exposes sensor_ids, revoke(), register_open_device(), and the context-manager protocol.

Audit events#

Every grant creation, use, denial, and end emits an AuditEvent to the audit_hook passed to Registry.

FieldTypeDescription
sensor_idstrThe sensor involved.
outcomestrOne of granted, used, denied, ended.
t_wallfloatWall-clock timestamp.

The type carries no values field, so a reading cannot leak into the audit trail. An exception raised by the hook is swallowed rather than propagated.

Streaming#

Stream is both an iterator and a context manager. A fixed-capacity ring buffer sits between a background producer thread and the consumer.

import sensortap

with sensortap.stream("microphone.winrt.0", buffer_blocks=128) as s:
    for block in s:
        print(block.seq, block.status, len(block.values))
        if s.discarded_blocks:
            break
MemberTypeDescription
sensor_idstrThe streamed sensor id.
buffer_blocksintConfigured ring buffer depth.
discarded_blocksintCount of blocks dropped under backpressure.
applied_rate_hzfloat | NoneRate the backend actually applied.
achieved_rate_hz()float | NoneMeasured rate over the most recent 100 delivered blocks. None below 2 delivered blocks.
close()NoneRelease backend resources. Idempotent.
Backpressure

When the buffer is full, the oldest undelivered block is dropped, discarded_blocks increments, and the next block actually delivered carries status = degraded. seq is assigned at production time, before the buffer, so a discard leaves a gap in seq rather than renumbering.

While the buffer is empty and the stream is open, iteration blocks until a block arrives. Once the producer stops and the buffer drains, iteration raises StopIteration. Iterating an explicitly closed stream raises ClosedStreamError.

Resources are released by close(), by garbage collection through weakref.finalize if the stream is dropped without closing, or by an atexit backstop. Some backends permit only one concurrent stream per sensor; a second request raises StreamBusyError and leaves the existing stream open and unaffected.

Backend status#

backend_status() returns one BackendStatus per adapter, maintained continuously rather than computed on demand.

FieldTypeDescription
adapter_idstrThe adapter's stable identifier.
state"loaded" | "not_loaded" | "degraded" | "unknown"Current state.
discovered_countintSensors reported by the most recent enumeration.
reasonNotLoadedReason | NonePopulated only when state is not_loaded.
remediationstr | NoneHuman-readable hint, 1 to 500 characters.
unsatisfied_depstuple[DependencySpec, ...]Up to 20 entries, each with name and version_constraint.
last_timeout_msint | NoneThe discovery timeout exceeded on the most recent enumeration.
helper_state"running" | "not_running" | "failed_to_start" | NoneFor helper-dependent adapters.
introspection_failedboolTrue when the state could not be determined.
last_enumeration_msfloat | NoneDuration of the most recent enumeration.

NotLoadedReason is a closed set: unsupported_platform, missing_dependency, load_error, elevation_required, not_opted_in.

Loaded-with-zero-sensors is state="loaded" with discovered_count=0, which never collapses into a not_loaded record: reason is meaningful only when state is not_loaded.

Errors and exit codes#

All exceptions derive from SensortapError, so one except clause catches everything. Each carries structured fields, so no caller needs to parse a message string.

from sensortap.registry.errors import SensortapError, UnknownSensorError

Exit codes#

Frozen; part of the CLI's contract.

CodeMeaningExceptions
0Success
1Unexpected internal erroranything unanticipated
2Unknown or malformed command or optionargparse failure, InvalidTimeoutError, MalformedSensorIdError
3Unknown sensor idUnknownSensorError
4Sensor unavailable or device open failureSensorUnavailableError, DeviceOpenError, StreamBusyError, BlockPathRequiredError, UnsupportedOperationError
5Missing consentConsentError, InvalidConsentRequestError
6Read timeoutReadTimeoutError
7doctor found adapters that fail the conformance contract— (a finding, not an exception)

Exception reference#

ExceptionRaised whenKey fields
UnknownSensorErrorThe id is absent from the current enumeration.sensor_id, last_known_availability
MalformedSensorIdErrorThe id violates the grammar or length limits. No lookup is performed.sensor_id, offending_segment
BlockPathRequiredErrorread() was called on a buffer sensor.sensor_id
BlockSizeOutOfRangeErrorA requested block size is outside the supported range. Nothing is allocated.sensor_id, requested_block_size, min_block_size, max_block_size
ClosedStreamErrorA block was requested from a closed stream.sensor_id
StreamBusyErrorA second stream was requested where the backend permits one.sensor_id
RateOutOfRangeErrorA requested rate falls outside min..max.sensor_id, requested_rate_hz, min_rate_hz, max_rate_hz, supported_rates_hz
RateFixedErrorRate configuration was requested on a fixed-rate sensor.sensor_id
RateConflictErrorA rate change was requested on an open stream, or a second stream requested a different rate. The applied rate is unchanged.sensor_id, applied_rate_hz, requested_rate_hz
InvalidTimeoutErrorA discovery timeout is non-numeric or outside 100..60000 ms. The configured timeout is unchanged.supplied_value, min_ms, max_ms
UnsupportedOperationErrorAn optional adapter operation was requested that the owning adapter does not implement.sensor_id, operation
UnsupportedConfigKeyErrorA configuration key other than rate or block size was supplied.sensor_id, key
ConsentErrorA privacy-sensitive sensor was read without a grant. The device is left closed.sensor_id
InvalidConsentRequestErrorA grant used a wildcard, a pattern, or an empty set. No grant is created.reason
SensorUnavailableErrorA discovered sensor cannot currently be opened or read.sensor_id, reason
DeviceOpenErrorThe underlying device handle failed to open.sensor_id, reason
ReadTimeoutErrorA poll read exceeded its time budget.sensor_id, timeout_ms
Never raised to a caller

ValidationError and SchemaVersionError are constructed and recorded by the registry while dropping the offending record and keeping every other valid record from that adapter.

Shipped adapters#

Nine Windows adapters, all run against real hardware.

Adapter idKindsNotes
winrt_motionaccel, gyro, magnWinRT motion classes. Rate configurable through report interval.
winrt_orientationincline, orientation, hinge-angleAll derived: fused from multiple physical sensors.
winrt_lightlightAmbient light in lux.
winrt_cameracameraConsent-gated.
winrt_audiomicrophoneConsent-gated. buffer dtype, stream only.
win_batterybattery, power, temp, voltageCharge percentage, capacity, charge rate.
win_radioradio-signalWi-Fi signal, Bluetooth presence.
win_touchpadtouchpadCapacitive image is consent-gated.
hwmon_bridgetemp, fan, voltage, current, power, clock, loadBundled .NET helper wrapping LibreHardwareMonitor. Board/Super-IO/EC sensors behind --include-motherboard.
Not hardware

A tenth adapter, reference, ships as a synthetic contributor example. It requires no hardware and exposes one sensor per dtype, each demonstrating a different path an adapter has to get right. It is not hardware and its readings are not measurements.

SensorDemonstrates
temp.reference.0The normal push path, plus the stale-value rule: read() reports stale rather than passing off an old value as ok. The one to use when you want an example that returns a number.
accel.reference.0The never-received-a-value path. It is present and deliberately never produces a sample, so read() returns immediately with status = unavailable and empty values instead of blocking forever. The fixture working correctly, not a broken sensor.
touchpad.reference.0The plain synchronous poll path.
microphone.reference.0The block/stream path; buffer dtype, so stream() only.

Worth knowing before you meet it: a sensor that is present but reads unavailable looks like a bug and is not one. That combination is legal and load-bearing across the whole project — a device can exist while a current value does not.

Helper process security#

hwmon_bridge spawns a child process and speaks newline-delimited JSON over a named pipe. Python is the pipe server; the helper is the client.

  • The pipe carries a per-user security descriptor and rejects remote clients.
  • A single-use launch token is delivered over the child's stdin, never on the command line.
  • The helper's first message must be exactly HELLO <token>, compared in constant time.
  • The connecting process id is verified against the spawned child's.
  • Any authentication failure closes the pipe and terminates the child immediately, with no retry.
  • Total readiness budget is 10 seconds. Failure marks the adapter unavailable rather than raising, so every other adapter still enumerates.

sensortap never installs, registers, or starts a kernel driver.

Writing an adapter#

One sensor family is one file, registered through a standard Python entry point. No edit to sensortap's own source is needed.

[project.entry-points."sensortap.adapters"]
my_widget = "my_package.adapter:MyWidgetAdapter"

The interface is a typing.Protocol, so structural typing applies and your class needs no import-time dependency on a sensortap base class.

AdapterMeta#

Declared as a class variable. The registry reads it without instantiating your class, so the platform and version gates cost no side effects.

FieldTypeDefaultDescription
adapter_idstrrequiredStable, lowercase identifier.
interface_versionstrrequired"MAJOR.MINOR". MAJOR must match the core's 1.0; MINOR is advisory.
supported_platformsfrozenset[str]requiredPlatform tags, for example {"win32"}.
priorityint | NoneNoneResolves id conflicts between adapters. None sorts lowest.
requires_elevation_optinboolFalseWhen True, the adapter loads only under --include-elevated.
read_only_declaredboolFalseMust be True or the registry refuses to load the adapter.

Required methods#

MethodContract
discover() -> Sequence[SensorInfo]Enumerate everything this adapter can report. Must not open camera, microphone, or HID device handles; those open lazily on first read or stream.
read(sensor_id) -> ReadingReturn the current value for one owned sensor. Never called for a buffer sensor.

Optional methods#

A missing optional method is not a load-time error. The registry raises UnsupportedOperationError only when a caller actually invokes that operation, and the adapter's other sensors stay readable through read().

MethodPurpose
open_stream(sensor_id, *, block_size, rate_hz, buffer_blocks)Open a block/streaming path. Returns a StreamSource.
supported_block_sizes(sensor_id)Minimum and maximum block size.
configure_rate(sensor_id, rate_hz)Request a rate; return the rate actually applied. Raise RateFixedError for fixed-rate sensors.
setup()One-time initialization after instantiation.
teardown()Release adapter-level resources. Called at most once.
health()Report detail beyond BackendStatus. Exposes status and detail.

StreamSource#

MethodContract
next_block() -> ReadingReturn the next block. Blocks until one is ready or the stream closes.
close() -> NoneRelease backend resources. Must complete within 1000 ms.
achieved_rate() -> float | NoneMeasured delivery rate. None below 2 samples.
applied_rate() -> floatThe rate actually applied, which may differ from the request.

Buffering, discard-on-backpressure, and the iterator surface belong to the registry's Stream, not to your adapter.

Conformance check#

A reusable check ships inside the package, so you can validate your adapter against the same rules the nine Windows adapters were checked against, with no access to sensortap's repository:

$ python -m sensortap.adapters.conformance my_package.adapter:MyWidgetAdapter

Exits 0 when the report passes, 1 when it fails, 2 on a usage or construction error.

It runs five checks: schema compliance of discovered records, Sensor_Id stability across consecutive discover() calls, reachability of every discovered sensor through read() or open_stream(), adapter-level error behaviour, and the declared read-only obligation. sensortap doctor runs the same check against every adapter loaded on the current machine.

Load sequence#

  1. Entry points are discovered and sorted by (distribution_name, entry_point_name) for a deterministic load order.
  2. AdapterMeta is read off the class without instantiating it.
  3. A platform or interface-MAJOR mismatch is a skip, not a failure.
  4. An adapter requiring elevation opt-in is skipped unless the caller opted in.
  5. Import, instantiation, and setup() are bounded at 5000 ms in total. An overrun or raise is recorded as a load failure, retained for the process lifetime, and iteration continues with the remaining adapters.
  6. An adapter whose read_only_declared is not True is refused.

One bad adapter never aborts loading the rest.

Post-collection pipeline#

Records returned by discover() pass through six ordered steps:

  1. Validate every record. An invalid record is dropped individually; that adapter's other records survive.
  2. De-duplicate on byte-for-byte id equality only. No fuzzy matching.
  3. Resolve conflicts between different adapters reporting the same id, by declared priority, ties going to the first loaded. The winner's source lists every reporting adapter.
  4. Disambiguate collisions where one adapter reports the same id for genuinely distinct sensors, by appending a suffix. Every sensor is kept.
  5. Order by adapter load order, then ascending id.
  6. Filter on kind, source, and id.

Guarantees and limits#

Guaranteed#

  • Importing sensortap loads no adapters and spawns no processes.
  • Enumeration never opens a device or triggers a permission prompt.
  • Reading a camera, microphone, or touchpad capacitive image requires an explicit per-id consent grant. Grants live in memory only.
  • No kernel driver is ever installed, registered, or started.
  • The interface defines no actuation path: no method transmits a setpoint, control value, or firmware payload. Acquisition configuration is limited to sampling rate and block size.
  • Sensor ids are stable across runs on unchanged hardware.
  • t_mono never decreases for a sensor, and concurrent readers get distinct seq values.
  • One adapter's failure, timeout, or exception never prevents other adapters from loading or enumerating.
  • The public API is identical on every platform. Nothing in the core branches on the operating system.
  • Every enum value set is identical across platforms.

Not guaranteed, or not built#

  • Linux. The registry and adapter interface are already platform-neutral and nothing in the core imports anything Windows-specific, but no Linux backend exists yet. This is the clearest open contribution: sysfs hwmon, IIO, V4L2, ALSA, evdev, one file per family.
  • A sensor's existence. A WinRT class the operating system does not expose reports absent rather than guessing whether the physical chip is there, since a wrong reading is harder to debug than a missing one.
  • read_only_declared enforcement. It is a declaration the registry requires, not a property it can verify inside an adapter's internals.
  • Unit semantics. unit is validated as a token, not as a UCUM expression.
  • Motherboard and EC sensors. Available only under an explicit opt-in, and absent entirely where no Ring 0 driver is already present or no Super-IO chip is recognised.
  • Adoption figures. None exist to quote, and none are invented.
  • A touchpad's maximum contact count. Touchpad presence is detected from the HID usage tables — usage page 0x0D (Digitizer), usage 0x05 (Touch Pad) — which is vendor-neutral and separates a touchpad from a touchscreen (0x04) or a pen (0x02). A maximum-contacts figure lives in the HID report descriptor, and parsing it requires opening the device, which discover() never does. Where WinRT's PointerDevice does not also surface the touchpad, touchpad.win-ptp.0 is present while touchpad.win-contacts.0 is absent: the touchpad exists and its contact count is not obtainable without acquisition.
  • Correct behaviour on hardware the maintainer does not own. Adapters are written against the schema contract and the test suite proves it holds for the hardware available. It cannot prove anything about hardware it has never seen. sensortap doctor exists so the check runs where the hardware is.