Skip to content

Fix ADS1x15 init across library versions + differential mode - #17

Open
msallin wants to merge 4 commits into
openplotter:masterfrom
msallin:fix/ads1x15-robust-pins-differential
Open

Fix ADS1x15 init across library versions + differential mode#17
msallin wants to merge 4 commits into
openplotter:masterfrom
msallin:fix/ads1x15-robust-pins-differential

Conversation

@msallin

@msallin msallin commented May 24, 2026

Copy link
Copy Markdown

Summary

Four related changes to the ADS1115/ADS1015 read path. Builds on the work in #16 with a more portable pin-constant resolution and adds two follow-ups.

  1. Sanitise sensor dict before persisting to conf — carries over the second half of Fix/ads1x15 pin constants #16. The read service mutates data[n]['object'] = AnalogIn(...) then str(i2c_sensors)-stringifies the whole thing, which produces <AnalogIn object at 0x...> and breaks the next eval() load. Build a serialisable copy at save time that strips runtime-only object and ranges keys. Channels stop disappearing from the UI after edits. Credits LeifYKlasson.
  2. Robust pin-constant resolution — the previous attempt in Fix/ads1x15 pin constants #16 hardcoded Pin.A0..A3, which only exists in newer versions of adafruit-circuitpython-ads1x15. Pi installations with the older library (where Pin has no A0 attribute) hit AttributeError and the whole ADS1115 sensor goes red. Try three APIs in order — newer Pin.A0..A3, mid-era ads1x15.P0..P3 module-level, oldest ads1115.P0..P3 module-level — and use whichever resolves. Verified on a Pi where the original PR's fix raises AttributeError.
  3. Fix getPaths2 publishing null for valid 0.0 readingsgetPaths2 uses result = '' as a "no range matched" sentinel and emits null when result is falsy. But 0.0 is falsy too, so a legitimate range-mapped value of zero is silently turned into null. Concrete trigger: a 4-20 mA tank sender at empty tank (4 mA → raw 4800 → result 0.0). Use result != '' instead.
  4. Optional differential-mode support for ADS1115/ADS1015 — the ADS1x15 hardware can read pairs (A0-A1, A0-A3, A1-A3, A2-A3) for common-mode noise rejection. The Adafruit driver already supports it via two-arg AnalogIn(ads, P0, P1). Expose through a new sensorSettings['diff'] field accepting "A0-A1" or "A0-A1, A2-A3". No GUI change required: the existing free-form sensor-settings dialog already accepts arbitrary keys. Backwards-compatible — when the setting is absent, behaviour is identical to single-ended.

Relationship to #16

This PR supersedes #16's pin-constant fix with a version-portable approach, and preserves #16's save-time sanitiser (with Co-Authored-By credit on that commit). If you'd prefer to land #16 first and then layer just the new bits on top, the differential-mode commit is the only one that depends on the pin work; the getPaths2 fix is independent and could be split out trivially.

Configuration example

Read a 4-20 mA tank sender across a 150 Ω shunt as a differential pair on A0/A1:

sensorSettings:
  gain = 1
  diff = A0-A1

A0 channel:
  SKkey       = tanks.fuel.0.currentLevel
  magnitudeSettings:
    range1 = 4800|28800 -> 0|1

A1/A2/A3 channels left unconfigured (A1 is consumed by the differential pair).

Test plan

  • ADS1115 single-ended A0..A3 reading still works (no diff setting → identical code path as before).
  • ADS1115 differential A0-A1 reading produces the same currentLevel as a standalone Python script using AnalogIn(ads, P0, P1).
  • Pin resolution works on a Pi where adafruit_ads1x15.ads1x15.Pin.A0 does not exist (falls through to module-level P0..P3).
  • Channels with magnitudeSettings mapping to 0.0 now publish 0, not null.
  • Channels no longer disappear from the GUI after editing (no AnalogIn-in-conf corruption).

Open:

  • ADS1015 single-ended A0..A3 reading (no hardware to test; code path is symmetric to ADS1115 and uses the same _A0.._A3 bindings).
  • ADS1015 differential mode (same caveat).

msallin and others added 4 commits May 24, 2026 14:41
The read service mutates `i2c_sensors[i]['data'][n]` at runtime to attach
an `AnalogIn` instance under the `object` key (and a derived `ranges`
list). When the whole dict is later persisted via
`conf2.set('I2C','sensors', str(i2c_sensors))`, the `AnalogIn` instance
serialises as `<AnalogIn object at 0x...>` which is not valid Python.

The other side of the round-trip, `eval(conf.get('I2C','sensors'))`,
then fails and the bare `except` falls through to an empty list. The
visible symptom is that all configured I2C channels disappear from the
GUI even though Signal K continues to receive readings until the next
service restart.

Build a serialisable copy at save time that excludes the runtime-only
`object` and `ranges` keys from each `data` entry. The sensor-level
`error` key is preserved so the UI can still highlight failing sensors.

This carries over the second half of openplotter#16 (the pin-constant fix is
handled separately in this PR; see the next commit).

Co-Authored-By: LeifYKlasson <leif.klasson@knowit.se>
The `adafruit_ads1x15` library has shipped at least three incompatible
pin-constant APIs:

  * Oldest: `from adafruit_ads1x15.ads1115 import P0, P1, P2, P3`
    (module-level on each chip module).
  * Mid-era: `from adafruit_ads1x15.ads1x15 import P0, P1, P2, P3`
    (module-level on the shared base module).
  * Newer: `from adafruit_ads1x15.ads1x15 import Pin` with class
    attributes `Pin.A0..A3`.

The previous attempt in openplotter#16 hard-coded `Pin.A0..A3`, which raises
`AttributeError: type object 'Pin' has no attribute 'A0'` on Pi
installations that still have the older library version pinned by
apt/debian. Tested against an ADS1115 at 0x48 on a Raspberry Pi where
`Pin.A0` does not exist.

Try the three forms in newest-first order and use whichever resolves.
The constants land in local `_A0`..`_A3` bindings used at the AnalogIn
construction sites; the surrounding unrolled per-channel code is
unchanged in this commit (a small refactor follows separately).

Closes the bug-1 half of openplotter#16 with a version-portable approach.
`getPaths2` initialises `result = ''` (empty string) as a sentinel and
sets it to a numeric value when a range matches. The publication guard
`if result:` was intended to distinguish "no range matched" from "we
have a value", but `0.0` is falsy in Python — so a legitimate mapped
value of zero falls into the `else` branch and Signal K receives
`null` instead of `0`.

Concrete trigger: a 4-20 mA tank-level sensor on an ADS1115 in
differential mode with `range1 = 4800|28800 -> 0|1`. At empty tank (4
mA → raw 4800) the linear interpolation yields `result = 0.0`, which is
the correct value but never gets published.

Use `result != ''` instead, which keeps the "no range matched" branch
intact while permitting all real numeric values (including 0 and any
negative outputs from inverted ranges).
The ADS1115/ADS1015 hardware supports four differential measurement
pairs: A0-A1, A0-A3, A1-A3, A2-A3 (plus the four single-ended
A0/A1/A2/A3 modes already supported). Differential mode rejects
common-mode noise and is the preferred way to read e.g. 4-20 mA
current-loop transmitters across a shunt resistor.

The Adafruit driver already supports differential reads via the
two-argument form `AnalogIn(ads, P0, P1)`; this PR exposes it through
a new optional `sensorSettings['diff']` field with no GUI change
required (the existing free-form key=value sensor-settings dialog
accepts arbitrary keys).

Configuration format:

    diff = A0-A1               # single differential pair
    diff = A0-A1, A2-A3        # two pairs simultaneously

Each pair occupies the channel slot of its positive pin: `A0-A1` lives
on the slot otherwise used by single-ended A0, `A2-A3` on the A2 slot.
Channels without a configured pair continue to read single-ended.
Invalid pairs are silently ignored (the existing init try/except
prevents bad strings from disabling the whole sensor) and the channel
falls back to single-ended.

The four unrolled `if data[n]['SKkey']: ... AnalogIn(...)` lines per
chip type are condensed into a single `for _ii in range(4)` loop that
picks one- or two-argument `AnalogIn` based on the diff map. Behaviour
is preserved when no `diff` setting is present.

Verified on an ADS1115 at 0x48 reading a 4-20 mA tank sender across a
150 Ω shunt: `diff = A0-A1` with `range1 = 4800|28800 -> 0|1` produces
the same currentLevel reading as a standalone Python script using
`AnalogIn(ads, P0, P1)`.
@LeifYKlasson

Copy link
Copy Markdown

Good work! I missed these parts. If I understand it correctly, you have my changes in our PR plus your changes? So I can just close my PR? I'm completely ok with that, doesn't need the the credit for it :-), but of course ok if it is mentioned. Then I will also update my files and run them.

/Leif

@msallin

msallin commented May 25, 2026

Copy link
Copy Markdown
Author

@LeifYKlasson yes, it does include your changes. It was simplest to reapply them within one PR. Would be great when you can test it and report back if it works with your setup!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants