Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ This is a repository for attempting to make my home ceiling fans [smart like thi

There is a [plan for dealing with the fans](docs/plans/2026-03-08-fan-control-phase1.md).

## TPMS ingest (issue #7)

Slice-1 lives under `src/tpms/`: read rtl_433 JSON from stdin, write to SQLite,
expose a small JSON API for a future home dashboard. Scope for slice-1 is
parked-only, any-tire-low alerting for the 2013 Mazda CX-9 (315 MHz, decoder
r156). Systemd units + install ritual: [`deploy/plexpi/`](deploy/plexpi/README.md).

## What We Are Working With

### Software
Expand Down
76 changes: 76 additions & 0 deletions deploy/plexpi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# TPMS slice-1 deploy on plexpi

Manual install ritual — plexpi does not have `itguy` deployment. Bluelinky and
fuelbot follow the same pattern (clone + venv + systemd unit under `pi`).

Both units are `systemctl --user` units, so they inherit the `pi` user
identity automatically — no `User=`/`Group=` directives (which systemd rejects
in `--user` units).

## Prereqs

1. RTL-SDR dongle plugged into plexpi USB, antenna facing the driveway.
2. `rtl_433` installed (`sudo apt install rtl-433`).
3. Confirm reception near the parked CX-9:

```bash
rtl_433 -M utc -f 315000000 -R 156 -M level
```

You should see periodic decoded events tagged `Abarth-124Spider`. If nothing
arrives after a few minutes, walk closer to the vehicle or drive the car
briefly to wake the sensors. `-M utc` is important — without it, rtl_433
emits system-local timestamps and the ingest daemon will skew every reading
by the local UTC offset.

## Install

```bash
ssh -i ~/.ssh/id_claude pi@192.168.68.54

# Data and log directories live under $HOME so no sudo is needed and
# systemd --user units can write freely.
mkdir -p ~/.local/share/tpms ~/tpms-logs

cd ~
git clone https://github.com/tclancy/radiofrequency tpms
cd tpms
uv sync

mkdir -p ~/.config/systemd/user
cp deploy/plexpi/tpms-capture.service ~/.config/systemd/user/
cp deploy/plexpi/tpms-api.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now tpms-api.service
systemctl --user enable --now tpms-capture.service
sudo loginctl enable-linger pi # so --user units survive logout
```

## Verify

```bash
# Ingest capturing something
journalctl --user -u tpms-capture -f

# API responding
curl http://192.168.68.54:8090/api/vehicles
curl http://192.168.68.54:8090/api/vehicles/mazda-cx9/tpms/latest
curl http://192.168.68.54:8090/api/health
```

The first `latest` response is likely `{"readings": [], ...}` until the CX-9's
sensors wake up (parked sensors transmit ~once per 60 s, sometimes much less).
`/api/health` returns `receiver_ok: false` until the first reading lands.

## Sensor IDs on record

Slice-1 does not map sensor→corner (any-tire-low alerting only). The `sensors`
table populates automatically as new sensor IDs are seen — no manual seed.

## Env vars

| Var | Default | Purpose |
|-----|---------|---------|
| `TPMS_DB` | `~/.local/share/tpms/tpms.sqlite3` | SQLite path (both services share) |
| `TPMS_LOW_PSI` | `30.0` | Threshold that sets `any_low: true` in `/latest` |
| `TPMS_STALE_SECONDS` | `900` | Reading is `stale: true` after this many seconds; `receiver_ok` flips false past the same window |
18 changes: 18 additions & 0 deletions deploy/plexpi/tpms-api.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[Unit]
Description=TPMS JSON API — reads tpms.sqlite3, serves :8090
Documentation=https://github.com/tclancy/radiofrequency/issues/7
After=network.target

[Service]
Type=simple
Environment=TPMS_DB=%h/.local/share/tpms/tpms.sqlite3
Environment=TPMS_LOW_PSI=30.0
Environment=TPMS_STALE_SECONDS=900
WorkingDirectory=%h/tpms
ExecStart=%h/tpms/.venv/bin/python -m src.tpms.api
Restart=on-failure
RestartSec=15
StandardError=append:%h/tpms-logs/api.log

[Install]
WantedBy=default.target
20 changes: 20 additions & 0 deletions deploy/plexpi/tpms-capture.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[Unit]
Description=TPMS capture — rtl_433 -> ingest daemon (Mazda CX-9, 315 MHz decoder r156)
Documentation=https://github.com/tclancy/radiofrequency/issues/7
After=network.target

[Service]
Type=simple
Environment=TPMS_DB=%h/.local/share/tpms/tpms.sqlite3
# rtl_433 streams JSON on stdout; the ingest daemon reads its stdin. Piping via
# systemd keeps this to a single unit — no MQTT broker required for slice-1.
# `-M utc` forces UTC timestamps in the JSON output — without it rtl_433 uses
# system-local time and the ingest daemon would silently skew every reading.
ExecStart=/bin/sh -c '/usr/bin/rtl_433 -M utc -f 315000000 -R 156 -F json 2>>%h/tpms-logs/rtl_433.log | %h/tpms/.venv/bin/python -m src.tpms.ingest'
Restart=on-failure
RestartSec=15
WorkingDirectory=%h/tpms
StandardError=append:%h/tpms-logs/capture.log

[Install]
WantedBy=default.target
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"click>=8.1",
"flask>=3.1.3",
"httpx>=0.27",
"matplotlib>=3.10",
"numpy>=1.26",
Expand Down
7 changes: 7 additions & 0 deletions src/tpms/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""TPMS ingest slice-1 — parked-only tire-pressure capture for a home dashboard.

Scope: one vehicle (2013 Mazda CX-9), one frequency (315 MHz), rtl_433 decoder r156.
Reads JSON events from rtl_433 (stdin), writes to SQLite, exposes a small JSON API.

See docs/tpms-slice-1.md for the deployment plan on plexpi.
"""
166 changes: 166 additions & 0 deletions src/tpms/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Flask JSON API for the home dashboard to read TPMS state.

Endpoints:
GET /api/vehicles
List known vehicles (slice-1: mazda-cx9 only).
GET /api/vehicles/<slug>/tpms/latest
Latest reading per sensor, one entry per sensor_id. Empty list is
legitimate (no readings yet); the client uses `/api/health` to tell
empty-because-parked from empty-because-broken.
GET /api/vehicles/<slug>/tpms/history?since=<unix>&until=<unix>&limit=<n>
Raw readings within the window. Defaults: last 7 days, limit 5000.
GET /api/health
Overall health — last event timestamp, receiver_ok flag.

Design notes:
- No auth. LAN-only service, mounted behind whatever the plexpi already fronts.
- One SQLite connection per request keeps thread-safety trivial and lets us
swap `TPMS_DB` in tests without touching Flask internals.
- Pressure is stored in kPa; response payloads carry both kPa and PSI so the
dashboard doesn't have to know the conversion.
"""

from __future__ import annotations

import sqlite3
import time
from pathlib import Path

from flask import Flask, jsonify, request

from . import db
from .config import Config

_KPA_TO_PSI = 0.14503773773


def kpa_to_psi(kpa: float | None) -> float | None:
return None if kpa is None else round(kpa * _KPA_TO_PSI, 1)


def _serialize_reading(row) -> dict:
kpa = row["pressure_kpa"]
return {
"ts": row["ts"],
"sensor_id": row["sensor_id"],
"pressure_kpa": kpa,
"pressure_psi": kpa_to_psi(kpa),
"temperature_c": row["temperature_c"],
"battery_ok": row["battery_ok"],
}


def create_app(config: Config | None = None) -> Flask:
cfg = config or Config.from_env()
app = Flask(__name__)
app.config["TPMS_CONFIG"] = cfg

# Init schema once at app startup so per-request connects can skip the
# `CREATE TABLE IF NOT EXISTS` script and vehicle upsert. The API is a
# concurrent reader alongside the ingest daemon — extra write-lock traffic
# per request would contend needlessly on the plexpi SQLite file.
db.connect(cfg.db_path).close()

@app.get("/api/vehicles")
def list_vehicles():
with _connect(cfg.db_path) as conn:
rows = conn.execute(
"SELECT slug, make, model, year, vin, frequency_hz, decoder "
"FROM vehicles ORDER BY slug"
).fetchall()
return jsonify([dict(r) for r in rows])

@app.get("/api/vehicles/<slug>/tpms/latest")
def latest(slug: str):
with _connect(cfg.db_path) as conn:
if not _vehicle_exists(conn, slug):
return jsonify({"error": "unknown vehicle"}), 404
now = int(time.time())
sensor_ids = db.vehicle_sensor_ids(conn, slug)
readings = []
any_low = False
all_stale = bool(sensor_ids)
for sid in sensor_ids:
row = db.latest_reading_for_sensor(conn, sid)
if row is None:
continue
item = _serialize_reading(row)
item["stale"] = (now - row["ts"]) > cfg.stale_seconds
if not item["stale"]:
all_stale = False
if (
item["pressure_psi"] is not None
and item["pressure_psi"] < cfg.low_psi
):
any_low = True
readings.append(item)
return jsonify(
{
"slug": slug,
"readings": readings,
"any_low": any_low,
"all_stale": all_stale,
"low_psi_threshold": cfg.low_psi,
}
)

@app.get("/api/vehicles/<slug>/tpms/history")
def history(slug: str):
now = int(time.time())
since = int(request.args.get("since", now - 7 * 86400))
until = int(request.args.get("until", now))
limit = min(int(request.args.get("limit", 5000)), 20000)
with _connect(cfg.db_path) as conn:
if not _vehicle_exists(conn, slug):
return jsonify({"error": "unknown vehicle"}), 404
rows = db.history_for_vehicle(conn, slug, since, until, limit)
return jsonify(
{
"slug": slug,
"since": since,
"until": until,
"count": len(rows),
"readings": [_serialize_reading(r) for r in rows],
}
)

@app.get("/api/health")
def health():
with _connect(cfg.db_path) as conn:
last = db.last_event_ts(conn)
now = int(time.time())
receiver_ok = last is not None and (now - last) <= cfg.stale_seconds
return jsonify(
{
"last_event_ts": last,
"receiver_ok": receiver_ok,
"stale_seconds": cfg.stale_seconds,
"now": now,
}
)

return app


def _connect(db_path: Path) -> sqlite3.Connection:
"""Lightweight read-side connection. Assumes create_app() already ran
`db.connect` once to init the schema.
"""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
return conn


def _vehicle_exists(conn, slug: str) -> bool:
row = conn.execute("SELECT 1 FROM vehicles WHERE slug = ?", (slug,)).fetchone()
return row is not None


def main() -> int: # pragma: no cover
app = create_app()
app.run(host="0.0.0.0", port=8090)
return 0


if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
32 changes: 32 additions & 0 deletions src/tpms/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Env-driven config for the TPMS ingest + API services.

Both services read the same config so a shared DB path and vehicle set stay in sync
without hand-wiring flags. `TPMS_DB` is the only required env var — everything else
falls back to sensible defaults for a plexpi single-vehicle install.
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from pathlib import Path

DEFAULT_DB_PATH = Path("/var/lib/tpms/tpms.sqlite3")
DEFAULT_STALE_SECONDS = 15 * 60
DEFAULT_LOW_PSI = 30.0


@dataclass(frozen=True)
class Config:
db_path: Path
stale_seconds: int
low_psi: float

@classmethod
def from_env(cls, env: dict[str, str] | None = None) -> "Config":
e = env if env is not None else os.environ
return cls(
db_path=Path(e.get("TPMS_DB", str(DEFAULT_DB_PATH))),
stale_seconds=int(e.get("TPMS_STALE_SECONDS", str(DEFAULT_STALE_SECONDS))),
low_psi=float(e.get("TPMS_LOW_PSI", str(DEFAULT_LOW_PSI))),
)
Loading
Loading