Turn live battery data sources into BDF feeds
Project description
battfeed
battfeed turns live battery data sources into BDF (Battery Data Format) feeds.
It is the acquisition layer of an open battery-data stack: point it at something that
produces battery readings — a simulator, a growing instrument log, the battery in your
Windows laptop, or your own cycler driver — and it polls that source on a schedule and
writes conforming .bdf.csv files (plus a .meta.json sidecar) that the rest of the
stack understands.
cyclers · instruments · OS batteries · live logs · simulators
│
│ poll() (you implement DataSource)
▼
battfeed (this package: Harvester + sinks)
│
▼
*.bdf.csv + *.meta.json (Battery Data Format files)
│
┌───────────────┼────────────────────┐
▼ ▼ ▼
batterydf BattINFO BDA registry
(normalize, (semantics, (publish,
validate, ontology) share)
analyze)
The core has zero runtime dependencies — everything is Python standard library. Requires Python >= 3.10.
Install
pip install battfeed
| Extra | Installs | Enables |
|---|---|---|
pip install battfeed[wmi] |
wmi |
WmiBatterySource (Windows laptop/tablet battery) |
pip install battfeed[mc3000-ble] |
bleak |
Mc3000Source over Bluetooth LE (the mock transport needs no extra) |
pip install battfeed[mc3000-usb] |
pyusb |
Mc3000Source over USB |
pip install battfeed[parquet] |
pyarrow |
ParquetSink — write captures as Parquet for analysis |
pip install battfeed[bdf] |
batterydf |
validate_file() — check emitted files against the BDF reference implementation |
pip install battfeed[dev] |
pytest, ruff, mypy |
development |
The Android source needs only the adb executable (Android platform-tools) on
your PATH; the dji import source needs the external dji-log binary (on your
PATH or via DJI_LOG_BIN). Neither needs a Python extra.
Quickstart
Collect from the built-in simulator into a BDF file:
from battfeed import BdfCsvSink, Harvester, create_source
harvester = Harvester()
harvester.register(create_source("simulator"))
sink = BdfCsvSink("LOCAL__DemoCell__20260707_001.bdf.csv", metadata={"operator": "me"})
harvester.collect("simulator", duration_s=10, interval_s=1.0, sink=sink)
sink.close() # finalises the CSV and writes the .meta.json sidecar
Or from the command line (Ctrl-C stops gracefully and finalises the files):
battfeed collect --source simulator --duration 10 --interval 1 --institution LOCAL --cell DemoCell
Omit --duration to collect until Ctrl-C — the mode field collectors run in.
Source constructor options are passed with repeatable --opt KEY=VALUE flags
(values are parsed as JSON when possible):
battfeed collect --source mc3000 --opt slot=1 --opt transport=ble --cell AA-Bay2
battfeed collect --source android --opt serial=R58M12ABC --interval 5
battfeed collect --source csvtail --opt path=instr.log --opt 'column_map={"V":"voltage_volt","I":"current_ampere"}'
battfeed sources lists everything available — including sources contributed by
other installed packages — with each source's options, and marks unavailable
ones (e.g. wmi off-Windows, mc3000 without its transport extra).
What comes out
A conforming BDF CSV with snake_case {quantity}_{unit} headers. The required trio
test_time_second, voltage_volt, current_ampere always leads the header, followed by
any extra columns in alphabetical order. Files are named
InstitutionCode__CellName__YYYYMMDD_XXX.bdf.csv (see battfeed.sinks.bdf_csv.dataset_filename).
Sign convention (per the BDF specification): positive current charges the test object, negative current discharges it. Power follows the same sign.
If a source does not stamp its own test_time_second, the harvester stamps each sample
with the elapsed collection time.
Sinks
A sink is where collected samples land. Everything above writes to a
BdfCsvSink, but battfeed ships four:
BdfCsvSink— one BDF.bdf.csvfile for one test object. The default; use it for a single cell, bay, or device. The sidecar is written early (marked"finalized": false) and finalised onclose(), so a crash mid-collection still leaves valid metadata on disk.RoutingSink— demultiplexes one stream into one BDF file per(series_id, run_id), with optional time/row rotation. Use it when one connection yields many objects or runs (see Multi-object routing below).HttpPushSink— POSTs samples as gzipped newline-delimited JSON to any HTTP endpoint on a time cadence, stdlib-only. Use it to feed a registry or lab server. Delivery is at-least-once: rows are kept on a failed POST and spooled to a loadable.spool.ndjsonif the endpoint stays down.ParquetSink— one Parquet file (plus the same.meta.jsonsidecar) per bounded capture, for analysis in pandas/Polars/DuckDB. Needsbattfeed[parquet]. Use it for finite analytical captures, not unbounded telemetry (that isRoutingSink+ BDF).
BdfCsvSink strips the routing keys (they are never BDF columns); the
Parquet and HTTP sinks keep them, as a column and payload field respectively,
so downstream code can group by object and run.
Multi-object routing
Two optional reserved keys on a sample say where it belongs rather than what was measured:
series_id— which physical object the sample is from (a car, a pack, a bay);run_id— which test-run segment it belongs to.
A source that emits them must supply its own test_time_second, zero-based per
(series_id, run_id) — the harvester's shared elapsed-collection clock is wrong
for an object that appears mid-run. RoutingSink sanitises raw series_id
device serials into safe BDF cell names, opens each object's file lazily on its
first sample, and reports what it wrote via files_by_series.
Importing files
Some sources ingest complete files rather than polling a live device — a
folder of exported logs, say. battfeed import drains such a source through the
same DataSource seam, writing through a RoutingSink so the result is one
.bdf.csv (plus sidecar) per (series_id, run_id):
battfeed import --source dji --opt path=C:/logs/dji --out-dir imported
One-shot by default — it stops when the source reports drained and prints
exactly what it wrote (or "nothing to import"); pass --watch to keep polling a
folder for new files until Ctrl-C. Import is at-least-once: a batch source
records a file as ingested only after its rows are safely written, so a crash
mid-import re-imports the file into new segment files rather than losing it.
Dedupe and quarantine are backed by an ImportLedger (sha256 content hashes;
permanently-unsupported files are quarantined with a reason and never retried).
Deleting output files does not reset the ledger; battfeed import --reset-ledger
does.
Configuration files
A long invocation — an mc3000 slot and BLE address, a dji folder and keychain
key — can live in a TOML file instead of a wall of --opt flags. Pass it with
--config on collect or import:
[collect] # defaults for `battfeed collect`
interval = 2.0
institution = "SINTEF"
cell = "Pack-07"
[import] # defaults for `battfeed import`
out_dir = "imported"
watch = true
[source.mc3000] # options for `--source mc3000`
slot = 1
transport = "ble"
address = "AA:BB:CC:DD:EE:FF"
[source.dji] # options for `--source dji`
path = "C:/logs/dji"
api_key = "${ENV:DJI_API_KEY}" # expanded from the environment at load time
A [source.<name>] block matches --source <name>; its optional type field
selects the registered source (defaulting to the block name, so a block can
alias one source under another name). Precedence, highest first:
- source options:
--opt KEY=VALUE>[source.<name>]> the source's own default - run parameters: the CLI flag >
[collect]/[import]> the built-in default
--config uses tomllib, standard library on Python 3.11+; on 3.10 install the
tomli backport (pip install tomli) — no dependency is added to the core.
Keeping secrets out of sight
Passing a token as --opt api_key=... leaks it into shell history and the
process list (ps / Task Manager show a process's full command line). Prefer a
config file with ${ENV:VAR} expansion, so the secret lives only in the
environment and never touches the file:
[source.push]
token = "${ENV:INGEST_TOKEN}"
Whatever the entry path, battfeed redacts credentials in two complementary
layers before they can reach a log, an error message, the battfeed sources
listing, or a .meta.json sidecar:
- by key name — a value whose key looks like a credential (
*key*,*token*,*secret*,*password*,*auth*, ...) is masked to***; - by value — the concrete secret values resolved for a run, plus any
scheme://user:pass@hostURL userinfo, are scrubbed even when they hide under an innocuous key.
The helpers are public — battfeed.config.redact_mapping, redact_text,
is_secret_key — for source authors who need them.
Writing your own source
battfeed.DataSource is a typing.Protocol — the stable seam third-party collectors
implement. No imports from battfeed are needed; any object with name, metadata() and
poll() qualifies:
class MyCyclerSource:
name = "my-cycler"
def metadata(self):
return {"source": self.name, "vendor": "ACME", "channel": 3}
def poll(self):
# Return zero or more NEW samples since the last call,
# keyed by canonical BDF column names.
reading = my_driver.read_channel(3)
return [{"voltage_volt": reading.volts, "current_ampere": reading.amps}]
Register it with a Harvester directly, or expose it to the battfeed CLI from your
own package via an entry point:
[project.entry-points."battfeed.sources"]
my-cycler = "my_pkg.sources:MyCyclerSource"
Sources may optionally define close() to release hardware handles, and an
availability() classmethod to explain why they cannot run here (missing extra,
wrong platform); battfeed uses both when present. See examples/custom_source.py
for a runnable version.
Keep sources simple: raise on trouble. When the device is unreachable,
poll() should raise — the Harvester retries with exponential backoff under a
configurable ErrorPolicy and only abandons the run (raising SourceFailure)
after too many consecutive failures. Sources should not implement their own
retry loops; only swallow errors you can genuinely resolve better yourself
(e.g. one bad frame out of several channels). This is what makes multi-day
field collection survive flaky Bluetooth and USB.
Test it with check_source. battfeed.testing.check_source asserts the
essentials every pipeline relies on — the name/metadata()/poll() surface,
strict-JSON metadata and samples, sample shape, and the routing-key discipline —
so a one-liner in your own test suite is a complete contract test:
from battfeed.testing import check_source
def test_my_source_contract():
check_source(MyCyclerSource(...)) # drive it with a mock or a replay tape, never live hardware
Push-style hardware (BLE, CAN, MQTT). Devices that push readings don't fit
a synchronous poll() directly. Subclass battfeed.StreamingSource: implement a
blocking run_reader(emit, should_stop) loop that calls emit(sample) for each
reading, and the base runs it in a background thread and buffers samples for
poll() to drain. The buffer is bounded and overflow is counted (never silently
lost); a reader that dies re-raises at the next poll() so the harvester's
ErrorPolicy owns the retry — no retry loop inside your reader. Because BLE/CAN
tooling is Linux-first and hardware isn't on every desk, the supported workflow
is replay-first: record raw frames from one live session into a JSONL tape
(battfeed.testing.ReplayTape / TapeRecorder), commit it, and drive every test
and most development from the tape with ReplayReader — which compresses time,
so an hour-long tape replays in milliseconds. See examples/streaming_source.py.
Built-in sources
| Name | Class | What it does |
|---|---|---|
simulator |
SimulatedCellSource |
Deterministic synthetic CR2032-ish discharge; ideal for demos and tests. |
csvtail |
CsvTailSource |
Tails a growing CSV log; you supply the column map and unit scale factors. |
wmi |
WmiBatterySource |
Polls the local Windows battery via WMI (battfeed[wmi], Windows only). |
mc3000 |
Mc3000Source |
SkyRC MC3000 charger/analyzer, one slot per instance, over BLE/USB (or a built-in mock transport for demos). |
android |
AndroidBatterySource |
Android device battery via adb (dumpsys + sysfs); pure stdlib. |
dji |
DjiFlightLogSource |
Import DJI Fly flight-log records (*.txt/*.dat) as one BDF file per (aircraft+battery, flight), via battfeed import. Wraps the external dji-log binary. Record files come from untrusted media, so battfeed passes the path safely to dji-log and quarantines anything malformed. Decrypting v13+ records calls DJI's keychain API. |
Non-goals
Keeping battfeed small is the point. It deliberately does not do:
- Vendor-file normalization. Parsing and harmonising exported Neware / BioLogic /
Digatron / Basytec / ... files is the job of
batterydf(Battery Data Alliance).CsvTailSourceis config-driven on purpose — it will never guess column synonyms. - Upload, fleet management, or multi-tenant services.
HttpPushSinkis a generic one-way POST to an endpoint you point it at — a sink, not a hosted service; battfeed runs no server and manages no fleet or tenants. Publishing and sharing belong to registry tooling. - Digital-twin or model logic. State estimation and twin orchestration live in
battwin, which consumes battfeed feeds.
Development
pip install -e ".[dev]"
pytest
Acknowledgements
This project has received support from European Union research and innovation programs under grant agreement 101103997 (DigiBatt).
License
Apache-2.0
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file battfeed-0.5.0.tar.gz.
File metadata
- Download URL: battfeed-0.5.0.tar.gz
- Upload date:
- Size: 177.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8d1fe5f29ecbe0fba8ad8551676fcbc3e352d1b76703dbcca3ca86b2f61931d2
|
|
| MD5 |
aba3b84303262e3cbaf9d0b54bc16b81
|
|
| BLAKE2b-256 |
19caecc3200eb9868609acb48d2433c2588a0c08376b7d1c87aacfcf71f6fbe3
|
Provenance
The following attestation bundles were made for battfeed-0.5.0.tar.gz:
Publisher:
publish.yml on DigiBatt/battfeed
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
battfeed-0.5.0.tar.gz -
Subject digest:
8d1fe5f29ecbe0fba8ad8551676fcbc3e352d1b76703dbcca3ca86b2f61931d2 - Sigstore transparency entry: 2213738012
- Sigstore integration time:
-
Permalink:
DigiBatt/battfeed@2fa86c885a9762ba2fb11b17aab2f480a22ef462 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/DigiBatt
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2fa86c885a9762ba2fb11b17aab2f480a22ef462 -
Trigger Event:
push
-
Statement type:
File details
Details for the file battfeed-0.5.0-py3-none-any.whl.
File metadata
- Download URL: battfeed-0.5.0-py3-none-any.whl
- Upload date:
- Size: 134.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
87e293b3ecadc936d295c9356541e19fc9bb8ac04ca43ac760ae75dbc2d84fcf
|
|
| MD5 |
7f532c9f091ef74c09bdeb1201f428b2
|
|
| BLAKE2b-256 |
eb1337d382d37ffc995584891b418114bf3c4669de86ec45ce29a0b4adf7dfd4
|
Provenance
The following attestation bundles were made for battfeed-0.5.0-py3-none-any.whl:
Publisher:
publish.yml on DigiBatt/battfeed
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
battfeed-0.5.0-py3-none-any.whl -
Subject digest:
87e293b3ecadc936d295c9356541e19fc9bb8ac04ca43ac760ae75dbc2d84fcf - Sigstore transparency entry: 2213738197
- Sigstore integration time:
-
Permalink:
DigiBatt/battfeed@2fa86c885a9762ba2fb11b17aab2f480a22ef462 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/DigiBatt
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2fa86c885a9762ba2fb11b17aab2f480a22ef462 -
Trigger Event:
push
-
Statement type: