A Home Assistant custom integration that polls ParemTech PTLevel tank level monitors directly on your local network — by IP address, straight from the device's built-in HTTP API. No cloud, no internet, no PTDevices account.
This is the local-polling counterpart to the official cloud-based PTDevices integration and implements what was requested in aioptdevices issue #10:
- Local only — works even when your internet is down, and no sensor data leaves your LAN.
- Fast — poll every few seconds if you want, instead of the ~60 s cloud cadence.
- Private — no account or API token required.
| Device | Local endpoint | Notes |
|---|---|---|
| PTLevel WiFi | GET /get_sensors |
Level + zero calibration |
| PTLevel Long Range (LoRa + WiFi) | GET /config?json=true |
Level, zero, battery voltage, RSSI, firmware/hardware info |
PTLevel v3 firmware (fw_v 400+) |
GET /get_data |
As above, plus the transmitter schedule, from a remotes list |
The integration probes all of these on the first poll, remembers which ones the device actually serves, and works with any of the three generations. See The local API for the payload differences.
v3 firmware needs one calibration step. Unlike the older generations it does not cache the cloud's full-tank value locally, so set Raw full level sensor on the device page before the Level percentage appears — see Calibrating the level percentage.
- In HACS, open the ⋮ menu → Custom repositories.
- Add this repository's URL with type Integration.
- Search for PTLevel Local in HACS and download it.
- Restart Home Assistant.
Copy custom_components/ptlevel_local/ into the custom_components/ folder of your Home Assistant configuration directory and restart Home Assistant.
- Give your PTLevel a static IP / DHCP reservation in your router (strongly recommended — the device does not advertise itself via mDNS, so the integration finds it only by address).
- In Home Assistant: Settings → Devices & services → Add integration → PTLevel Local.
- Enter the device's IP address.
If the device's IP changes later, use Reconfigure on the integration entry to update the address.
The device is identified by its MAC address, shown as the Serial number in Device info; the sensor and repeater firmware/hardware versions appear there too (e.g. Sensor 18 / Repeater 214). On Long Range models the sensor (TX) is the battery unit at the tank and the repeater (RX) is the mains-powered WiFi bridge that relays it — entity names use those terms. The integration deliberately does not register a network-MAC connection, so it won't merge with your router integration's client device (which would otherwise add throughput/uptime entities to the PTLevel card).
| Entity | Description | Enabled by default |
|---|---|---|
| Level | Fill level in %, computed from the raw reading and calibration (see below) | Yes |
| Raw level sensor | The raw pressure counts reported by the sensor (key 1), whole number |
Yes |
| Battery | Transmitter battery level in %, mapped from voltage (4 V = 0 %, 6 V = 100 %) | Yes |
| Temperature | Temperature probe in °C (key 3) on transmitters with hardware version 16 |
Yes |
| Sensor reading interval | How long the sensor hibernates between readings (x), diagnostic |
Yes |
| Required update interval | Guaranteed report cadence, sleep × update-count (z), diagnostic |
Yes |
| Immediate level change update | Change threshold that triggers an early report, in % (y), diagnostic |
Yes |
| Connection failures | The device's fails counter, diagnostic |
No |
| Raw calibration | The device's empty-tank raw value (key z), diagnostic |
No |
| Auxiliary reading | Undocumented extra reading (key 3) on older transmitters |
No |
| Sensor RSSI / Repeater RSSI | Sensor LoRa signal and repeater WiFi signal strength in dBm | No |
The reading-interval / required-update / immediate-change values come from the cloud config the device caches locally (see The local API); they are read-only here because the device has no local setter — change them in the PTDevices app. The cloud full-tank value (h) is used to auto-populate the Raw full level sensor rather than shown as a separate entity.
| Entity | Description |
|---|---|
| Raw full level sensor | The raw reading with the tank full, whole number. Required to compute Level %. |
| Raw empty level sensor | The raw reading with the tank empty. Defaults to the device's live zero (z) until you override it. |
In most cases you don't have to. If the device has ever linked to PTDevices.com, it caches the cloud calibration locally and the integration reads it automatically — the Raw full level sensor defaults to the cloud's full value (h), the Raw empty level sensor to the device zero (z), and Level just works out of the box:
level % = (raw − empty) / (full − empty) × 100
To override either value (e.g. a device never linked to the cloud, or you want a custom full point), set it on the device page under Configuration:
- Raw full level sensor — already defaults to the cloud
h; override it from the Raw level sensor with a full tank, or thehighestvalue on your PTDevices.com calibration page. - Raw empty level sensor — defaults to the live device zero; override only if needed.
A manual value always wins over the cloud default and persists in the config entry options. If neither a manual value nor a cloud h is available, the Level sensor stays unknown while Raw level sensor works immediately.
If you know your tank's capacity, a template sensor converts the percentage to volume:
template:
- sensor:
- name: "Cistern volume"
unit_of_measurement: "L"
state_class: measurement
state: >
{{ (states('sensor.ptlevel_192_168_1_100_level') | float(0) / 100 * 9000) | round(0) }}The Configure dialog holds the poll interval; calibration is set via the Configuration number entities above.
| Option | Default | Description |
|---|---|---|
| Poll interval | 60 s | How often to poll the device (5–3600 s). Polling is local and cheap. |
For reference (and until ParemTech documents it officially), this is what the devices serve. The embedded web server may not set a JSON content type; parse the body regardless.
{"local_s": "[{\"1\":314,\"z\":60}]"}local_s is a JSON-encoded string containing a list of single-reading objects.
{
"free_space": "667648",
"rx_id": "210",
"tx_id": "110",
"tx_rssi": "-101",
"rx_rssi": "-42",
"firmware_version": "210",
"hardware_version": "4",
"id": "CC50E36CF496",
"ip": "192.168.0.202",
"subnet": "255.255.255.0",
"gateway": "192.168.0.1",
"dns": "unknown",
"tx_firmware_version": "7",
"tx_hardware_version": "5",
"fails": "0",
"rx_sensors": "[]",
"tx_sensors": "[{\"1\":314,\"z\":60},{\"2\":5.83},{\"3\":7.12}]"
}Reading keys: 1 = raw level, z = zero calibration, 2 = battery voltage (V), 3 = auxiliary/undocumented. id is the device MAC address, used as the config entry's unique ID and shown as the Serial number. fails is a device-side error counter, exposed as the Connection failures diagnostic.
Verified against real Long Range hardware on firmware 214: /get_sensors also exists on these units but answers 200 with an empty body (the integration detects this and polls only /config?json=true), and the key order inside tx_sensors varies between transmitter models. The meaning of 3 depends on the transmitter hardware version: on tx hardware 16 it is a temperature probe in °C (verified: a local reading of 18.0 matched the cloud dashboard's 64.4 °F exactly), while on tx hardware 5 the observed values (5.48, 7.12) look like voltages. The integration exposes a real temperature sensor on known temperature-probe hardware and a unitless "Auxiliary reading" otherwise.
The plain /config page (HTML, not the ?json=true variant) embeds the device's last cloud check-in, whose data object is the schedule and calibration the PTDevices cloud pushed down to the device. The integration scrapes this best-effort to auto-calibrate and expose the schedule:
Last Check In Response 200: {"isError":false,...,"data":{"x":5,"y":13,"z":12,"d":1.4,"u":"metric","h":306},"pk":"…","tk":"…"}
| field | meaning |
|---|---|
x |
sleep minutes (device hibernation between readings) |
z |
regular-update wake count; forced-report minutes = z × x |
y |
Level Watch threshold as absolute raw counts; percent = y / (h − empty) × 100 |
h |
full-tank raw calibration (highest) — used to auto-populate the Raw full level sensor |
u / d |
units (metric/imperial) / tank dimension |
Verified across three schedules (5 %, 10 %, 3 % Level Watch) on two devices. The pk/tk are per-request cloud tokens — the integration ignores them and never logs them; treat them as secrets. The device exposes no local setter for these; changes must go through the cloud. The only local write actions are GET /link (LoRa pair mode), GET /disconnect (forget WiFi), and GET /reset_zero (reset the empty calibration) — the integration never calls these.
Newer firmware (fw_v 400+, sv: 3, dt: "LRW") replaces the whole scheme above: /config returns JSON instead of HTML, /get_sensors returns a bare array rather than an object, and unknown paths redirect to a captive portal instead of returning an empty 200. Field names are abbreviated:
{
"id": "D4F98D…", "mac": "D4F98D…", "fw_v": 409, "hw_v": 5,
"rx_rssi": -71, "ip": "…", "dt": "LRW", "sv": 3,
"local_s": [{"1": 17538, "z": 10184}, {"3": 23.9}, {"2": 6.6}],
"remotes": [{"tx_id": 248, "hw_v": 16, "fw_v": 18, "rssi": -89,
"fails": 0, "x": 30, "y": 1400, "z": 24, "rx_d": "[…]"}]
}| v3 key | Legacy equivalent |
|---|---|
mac / id |
id |
fw_v / hw_v |
firmware_version / hardware_version |
remotes[].fw_v / .hw_v |
tx_firmware_version / tx_hardware_version |
remotes[].rssi / .fails |
tx_rssi / fails |
remotes[].x / .y / .z |
the cloud check-in x / y / z |
local_s (array) or remotes[].rx_d |
tx_sensors |
The reading keys (1, z, 2, 3) are unchanged. There is no h anywhere in the v3 local API, which is why the full-tank value must be entered manually on these devices. /remotes also exists but reports x/y/z as booleans, so it is not used.
Security note: the v3
/get_databody contains the WiFi SSID and password in plaintext (sta_ssid,sta_pass), so any client on your LAN can read them. The integration drops these keys while parsing — they never reach entity state, logs, or the diagnostics download — but be careful when sharing raw captures of this endpoint.
Note: this API is undocumented and could change with a firmware update. If ParemTech formalizes it (see issue #10), this integration will track the documented behavior.
- "This PTLevel model is not supported yet": your device speaks a payload format no profile understands. Nothing is wrong with your setup — see Reporting an unsupported model to get it added.
- Level is
unknown: the device has no cloud calibration cached (never linked) and no manual value — set Raw full level sensor on the device page (see calibration above). - Cannot connect during setup: verify you can open
http://<ip>/get_sensors(WiFi model) orhttp://<ip>/config?json=true(Long Range model) in a browser on the same network. - Entities go unavailable after a router reboot: the device likely got a new DHCP lease — set a reservation, then use Reconfigure to update the IP.
ParemTech ships several PTLevel generations and the local API differs between them. If the integration meets one it does not recognise, it fails loudly and usefully rather than silently creating a device with no entities.
What you will see. Adding the device stops with:
This PTLevel model is not supported yet. A support report has been written to the Home Assistant log…
What to do.
- Open Settings → System → Logs, click Load full logs, and search for
PTLEVEL SUPPORT REPORT. - Copy everything between
--- BEGIN PTLEVEL SUPPORT REPORT ---and--- END PTLEVEL SUPPORT REPORT ---. - Open an issue and paste it in.
That block is all a maintainer needs — no access to your device is required to add support. It contains the raw payloads from every endpoint the device answered, the integration version, and which existing profile came closest:
{
"closest_profile": "v3",
"endpoints": {
"/get_data": { "id": "…", "fw_v": 500, "readings": { "depth_mm": 1234 } },
"/get_sensors": []
},
"integration_version": "1.1.0",
"known_profiles": ["v3", "long_range", "wifi"],
"missing_fields": ["raw_level"]
}Privacy: the report is redacted before it is written. WiFi credentials (sta_pass, sta_ssid, ap_p) and cloud tokens (pk, tk) are replaced with <redacted> — worth knowing, because v3 firmware really does serve your WiFi password over plain HTTP. Everything else is device metadata and sensor readings. Review it before posting if you like; it is plain JSON.
You can produce the same information yourself without Home Assistant:
python scripts/probe.py <ip>Model support is entirely declarative and lives in one file: custom_components/ptlevel_local/models.py. Nothing else in the integration knows about specific models, so adding one cannot change the behaviour of the others.
Each supported model is a DeviceProfile. On every poll the client fetches the known endpoints, merges the JSON objects into one payload, and walks PROFILES in order — first match wins, so profiles are ordered most-specific first with a catch-all last:
payload ──▶ detect_profile() ──▶ DeviceProfile ──▶ resolve declared paths ──▶ PTLevelData
If no profile can read a level but the device does identify itself, the client raises PTLevelUnsupportedModelError carrying the support report above.
A profile does not contain parsing code — it declares where each value lives. A path is a top-level key ("rx_rssi") or a tuple that walks into nested structures, where integers index lists:
("remotes", 0, "rssi")Each field maps to a tuple of candidate paths, tried in order; the first that resolves to a non-null value wins. Missing keys, wrong container types and out-of-range indices resolve to None instead of raising, so an unexpected payload degrades gracefully. Null checks are explicit, so a legitimate 0 (an RSSI of 0, a failure count of 0) is never mistaken for "absent".
Given a support report from an issue, add a profile to PROFILES in models.py:
PROFILE_NEW = DeviceProfile(
key="new_model", # internal id, also used in logs and tests
family=MODEL_LONG_RANGE, # or MODEL_WIFI
name="PTLevel Whatever", # shown as Model in the device registry
model_id="ptlevel-whatever", # fallback when the device reports no code
generation=4,
matches=lambda d: d.get("sv") == 4, # recognise it
fields={
"device_id": ("id", "mac"),
"firmware_version": ("fw_v",),
"tx_rssi": (("remotes", 0, "rssi"),), # nested path
},
sensor_paths=("local_s", ("remotes", 0, "rx_d")),
html_schedule=False, # True if the schedule is only in /config HTML
)Then register it in PROFILES before the more generic entries.
The reading keys inside a sensor list (1 = raw level, z = zero, 2 = battery volts, 3 = temperature or aux) are shared across all models so far; unrecognised keys are preserved in PTLevelData.extra rather than discarded. Two other pieces of device knowledge also live in models.py: TEMPERATURE_TX_HARDWARE (transmitter revisions whose 3 is a temperature probe) and SECRET_KEYS (payload keys that must never be logged).
Add the report's payload as a fixture in tests/conftest.py and a case in tests/test_models.py. Those tests run with no Home Assistant, no network and no device:
pytest tests/test_models.py -vtest_profile_registry_is_well_formed enforces the invariants a new profile must not break (unique keys, catch-all last, sensor paths declared), so a malformed contribution fails CI with a clear message.
uv venv --python 3.13 .venv
source .venv/bin/activate
uv pip install -r requirements-test.txt
pytest testsTo inspect a real device on your LAN (raw payloads plus the parsed snapshot, optionally with a calibration value to compute the level %):
python scripts/probe.py 192.168.1.100 [full_raw]MIT — see LICENSE. Not affiliated with ParemTech Inc. PTLevel and PTDevices are trademarks of their respective owners.
