Skip to content

Repository files navigation

PTLevel Local for Home Assistant

hacs_badge

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

Supported devices

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.

Installation

HACS (recommended)

  1. In HACS, open the ⋮ menu → Custom repositories.
  2. Add this repository's URL with type Integration.
  3. Search for PTLevel Local in HACS and download it.
  4. Restart Home Assistant.

Manual

Copy custom_components/ptlevel_local/ into the custom_components/ folder of your Home Assistant configuration directory and restart Home Assistant.

Setup

  1. 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).
  2. In Home Assistant: Settings → Devices & services → Add integration → PTLevel Local.
  3. 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).

Entities

Sensors

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.

Configuration (number entities)

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.

Calibrating the level percentage

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 the highest value 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.

Volume template (optional)

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) }}

Options

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.

The local API, documented

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.

PTLevel WiFi — GET http://<ip>/get_sensors

{"local_s": "[{\"1\":314,\"z\":60}]"}

local_s is a JSON-encoded string containing a list of single-reading objects.

PTLevel Long Range — GET http://<ip>/config?json=true

{
  "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.

Schedule + calibration — GET http://<ip>/config (HTML)

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.

v3 firmware — GET http://<ip>/get_data

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_data body 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.

Troubleshooting

  • "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) or http://<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.

Reporting an unsupported model

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.

  1. Open Settings → System → Logs, click Load full logs, and search for PTLEVEL SUPPORT REPORT.
  2. Copy everything between --- BEGIN PTLEVEL SUPPORT REPORT --- and --- END PTLEVEL SUPPORT REPORT ---.
  3. 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>

Adding a new device model

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.

How detection works

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.

Field paths

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

Adding a profile

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

Testing without the hardware

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

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

Development

uv venv --python 3.13 .venv
source .venv/bin/activate
uv pip install -r requirements-test.txt
pytest tests

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

License

MIT — see LICENSE. Not affiliated with ParemTech Inc. PTLevel and PTDevices are trademarks of their respective owners.

Releases

Packages

Contributors

Languages