From 5a15473b2839861f26f79e7954cae6a11fbca22e Mon Sep 17 00:00:00 2001 From: Marc Sallin Date: Sun, 24 May 2026 14:41:50 -0400 Subject: [PATCH 1/4] Strip runtime-only keys before persisting sensor config 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 `` 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 #16 (the pin-constant fix is handled separately in this PR; see the next commit). Co-Authored-By: LeifYKlasson --- openplotterI2c/openplotterI2cRead.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openplotterI2c/openplotterI2cRead.py b/openplotterI2c/openplotterI2cRead.py index 258bdc7..c86a627 100644 --- a/openplotterI2c/openplotterI2cRead.py +++ b/openplotterI2c/openplotterI2cRead.py @@ -294,7 +294,12 @@ def main(): sys.stdout.flush() else: i2c_sensors[i]['error'] = '' - conf2.set('I2C', 'sensors', str(i2c_sensors)) + i2c_sensors_save = {} + for _n, _s in i2c_sensors.items(): + _sc = {k: v for k, v in _s.items() if k != 'data'} + _sc['data'] = [{k: v for k, v in d.items() if k not in ('object', 'ranges')} for d in _s['data']] + i2c_sensors_save[_n] = _sc + conf2.set('I2C', 'sensors', str(i2c_sensors_save)) #read sensors if not instances: From 1cf16700553cfb84181988a33b19ac1cdbef9fe8 Mon Sep 17 00:00:00 2001 From: Marc Sallin Date: Sun, 24 May 2026 14:42:39 -0400 Subject: [PATCH 2/4] Resolve ADS1x15 pin constants across adafruit_ads1x15 versions 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 #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 #16 with a version-portable approach. --- openplotterI2c/openplotterI2cRead.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/openplotterI2c/openplotterI2cRead.py b/openplotterI2c/openplotterI2cRead.py index c86a627..3fee15e 100644 --- a/openplotterI2c/openplotterI2cRead.py +++ b/openplotterI2c/openplotterI2cRead.py @@ -240,6 +240,18 @@ def main(): elif i2c_sensors[i]['type'] == 'ADS1115' or i2c_sensors[i]['type'] == 'ADS1015': from adafruit_ads1x15.analog_in import AnalogIn + # Resolve ADS1x15 pin constants across library versions: + # newer: adafruit_ads1x15.ads1x15.Pin.A0..A3 + # older: module-level P0..P3 in adafruit_ads1x15.ads1x15 + # oldest: module-level P0..P3 in adafruit_ads1x15.ads1115 + try: + from adafruit_ads1x15.ads1x15 import Pin as _ADS_PinCls + _A0, _A1, _A2, _A3 = _ADS_PinCls.A0, _ADS_PinCls.A1, _ADS_PinCls.A2, _ADS_PinCls.A3 + except (ImportError, AttributeError): + try: + from adafruit_ads1x15.ads1x15 import P0 as _A0, P1 as _A1, P2 as _A2, P3 as _A3 + except (ImportError, AttributeError): + from adafruit_ads1x15.ads1115 import P0 as _A0, P1 as _A1, P2 as _A2, P3 as _A3 if i2c_sensors[i]['type'] == 'ADS1115': import adafruit_ads1x15.ads1115 as ADS11 if i2c_sensors[i]['channel'] == 0: @@ -248,10 +260,10 @@ def main(): else: if i2c_sensors[i]['address']: instances.append({'name':i,'type':'ADS1115','tick':[now,now,now,now],'sensor':i2c_sensors[i],'object':ADS11.ADS1115(muxInstances[i2c_sensors[i]['address']][i2c_sensors[i]['channel']-1])}) - if instances[-1]['sensor']['data'][0]['SKkey']: instances[-1]['sensor']['data'][0]['object'] = AnalogIn(instances[-1]['object'], ADS11.P0) - if instances[-1]['sensor']['data'][1]['SKkey']: instances[-1]['sensor']['data'][1]['object'] = AnalogIn(instances[-1]['object'], ADS11.P1) - if instances[-1]['sensor']['data'][2]['SKkey']: instances[-1]['sensor']['data'][2]['object'] = AnalogIn(instances[-1]['object'], ADS11.P2) - if instances[-1]['sensor']['data'][3]['SKkey']: instances[-1]['sensor']['data'][3]['object'] = AnalogIn(instances[-1]['object'], ADS11.P3) + if instances[-1]['sensor']['data'][0]['SKkey']: instances[-1]['sensor']['data'][0]['object'] = AnalogIn(instances[-1]['object'], _A0) + if instances[-1]['sensor']['data'][1]['SKkey']: instances[-1]['sensor']['data'][1]['object'] = AnalogIn(instances[-1]['object'], _A1) + if instances[-1]['sensor']['data'][2]['SKkey']: instances[-1]['sensor']['data'][2]['object'] = AnalogIn(instances[-1]['object'], _A2) + if instances[-1]['sensor']['data'][3]['SKkey']: instances[-1]['sensor']['data'][3]['object'] = AnalogIn(instances[-1]['object'], _A3) elif i2c_sensors[i]['type'] == 'ADS1015': import adafruit_ads1x15.ads1015 as ADS10 @@ -261,10 +273,10 @@ def main(): else: if i2c_sensors[i]['address']: instances.append({'name':i,'type':'ADS1015','tick':[now,now,now,now],'sensor':i2c_sensors[i],'object':ADS10.ADS1015(muxInstances[i2c_sensors[i]['address']][i2c_sensors[i]['channel']-1])}) - if instances[-1]['sensor']['data'][0]['SKkey']: instances[-1]['sensor']['data'][0]['object'] = AnalogIn(instances[-1]['object'], ADS10.P0) - if instances[-1]['sensor']['data'][1]['SKkey']: instances[-1]['sensor']['data'][1]['object'] = AnalogIn(instances[-1]['object'], ADS10.P1) - if instances[-1]['sensor']['data'][2]['SKkey']: instances[-1]['sensor']['data'][2]['object'] = AnalogIn(instances[-1]['object'], ADS10.P2) - if instances[-1]['sensor']['data'][3]['SKkey']: instances[-1]['sensor']['data'][3]['object'] = AnalogIn(instances[-1]['object'], ADS10.P3) + if instances[-1]['sensor']['data'][0]['SKkey']: instances[-1]['sensor']['data'][0]['object'] = AnalogIn(instances[-1]['object'], _A0) + if instances[-1]['sensor']['data'][1]['SKkey']: instances[-1]['sensor']['data'][1]['object'] = AnalogIn(instances[-1]['object'], _A1) + if instances[-1]['sensor']['data'][2]['SKkey']: instances[-1]['sensor']['data'][2]['object'] = AnalogIn(instances[-1]['object'], _A2) + if instances[-1]['sensor']['data'][3]['SKkey']: instances[-1]['sensor']['data'][3]['object'] = AnalogIn(instances[-1]['object'], _A3) gain = 1 if 'sensorSettings' in instances[-1]['sensor']: From efe8e79c01b9a7f0d045a3c8aee6857b4e488466 Mon Sep 17 00:00:00 2001 From: Marc Sallin Date: Sun, 24 May 2026 14:42:55 -0400 Subject: [PATCH 3/4] Fix getPaths2 publishing null for legitimate 0.0 readings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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). --- openplotterI2c/openplotterI2cRead.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openplotterI2c/openplotterI2cRead.py b/openplotterI2c/openplotterI2cRead.py index 3fee15e..16de221 100644 --- a/openplotterI2c/openplotterI2cRead.py +++ b/openplotterI2c/openplotterI2cRead.py @@ -45,7 +45,7 @@ def getPaths2(Erg,ranges,value,voltage,key,offset,factor,raw): d = c*pc/100 result = r2[0]+d else: result = r2 - if result: Erg.append({"path":key,"value":offset+(result*factor)}) + if result != '': Erg.append({"path":key,"value":offset+(result*factor)}) else: Erg.append({"path":key,"value":None}) if raw and voltage and value: if voltage: rawvoltage = voltage From 729cdcdc72e68faba58a4450e3bc74fb62345511 Mon Sep 17 00:00:00 2001 From: Marc Sallin Date: Sun, 24 May 2026 14:44:09 -0400 Subject: [PATCH 4/4] Add optional differential-mode support for ADS1115/ADS1015 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)`. --- openplotterI2c/openplotterI2cRead.py | 32 ++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/openplotterI2c/openplotterI2cRead.py b/openplotterI2c/openplotterI2cRead.py index 16de221..5a7d341 100644 --- a/openplotterI2c/openplotterI2cRead.py +++ b/openplotterI2c/openplotterI2cRead.py @@ -252,6 +252,20 @@ def main(): from adafruit_ads1x15.ads1x15 import P0 as _A0, P1 as _A1, P2 as _A2, P3 as _A3 except (ImportError, AttributeError): from adafruit_ads1x15.ads1115 import P0 as _A0, P1 as _A1, P2 as _A2, P3 as _A3 + # Optional differential-mode setting via sensorSettings['diff']. + # Format: comma-separated pairs, e.g. "A0-A1" or "A0-A1,A2-A3". + # Each pair occupies the channel slot of its positive pin (A0-A1 -> slot 0). + # Hardware-valid pairs on ADS1x15: A0-A1, A0-A3, A1-A3, A2-A3. + _ads_diff = {} + try: + _d = i2c_sensors[i].get('sensorSettings', {}).get('diff', '').strip() + if _d: + _pins = {'A0': _A0, 'A1': _A1, 'A2': _A2, 'A3': _A3} + for _pair in _d.split(','): + _p, _n = [x.strip() for x in _pair.split('-')] + _ads_diff[int(_p[1])] = (_pins[_p], _pins[_n]) + except: pass + _ads_default_pins = (_A0, _A1, _A2, _A3) if i2c_sensors[i]['type'] == 'ADS1115': import adafruit_ads1x15.ads1115 as ADS11 if i2c_sensors[i]['channel'] == 0: @@ -260,10 +274,10 @@ def main(): else: if i2c_sensors[i]['address']: instances.append({'name':i,'type':'ADS1115','tick':[now,now,now,now],'sensor':i2c_sensors[i],'object':ADS11.ADS1115(muxInstances[i2c_sensors[i]['address']][i2c_sensors[i]['channel']-1])}) - if instances[-1]['sensor']['data'][0]['SKkey']: instances[-1]['sensor']['data'][0]['object'] = AnalogIn(instances[-1]['object'], _A0) - if instances[-1]['sensor']['data'][1]['SKkey']: instances[-1]['sensor']['data'][1]['object'] = AnalogIn(instances[-1]['object'], _A1) - if instances[-1]['sensor']['data'][2]['SKkey']: instances[-1]['sensor']['data'][2]['object'] = AnalogIn(instances[-1]['object'], _A2) - if instances[-1]['sensor']['data'][3]['SKkey']: instances[-1]['sensor']['data'][3]['object'] = AnalogIn(instances[-1]['object'], _A3) + for _ii in range(4): + if instances[-1]['sensor']['data'][_ii]['SKkey']: + _args = _ads_diff[_ii] if _ii in _ads_diff else (_ads_default_pins[_ii],) + instances[-1]['sensor']['data'][_ii]['object'] = AnalogIn(instances[-1]['object'], *_args) elif i2c_sensors[i]['type'] == 'ADS1015': import adafruit_ads1x15.ads1015 as ADS10 @@ -273,10 +287,10 @@ def main(): else: if i2c_sensors[i]['address']: instances.append({'name':i,'type':'ADS1015','tick':[now,now,now,now],'sensor':i2c_sensors[i],'object':ADS10.ADS1015(muxInstances[i2c_sensors[i]['address']][i2c_sensors[i]['channel']-1])}) - if instances[-1]['sensor']['data'][0]['SKkey']: instances[-1]['sensor']['data'][0]['object'] = AnalogIn(instances[-1]['object'], _A0) - if instances[-1]['sensor']['data'][1]['SKkey']: instances[-1]['sensor']['data'][1]['object'] = AnalogIn(instances[-1]['object'], _A1) - if instances[-1]['sensor']['data'][2]['SKkey']: instances[-1]['sensor']['data'][2]['object'] = AnalogIn(instances[-1]['object'], _A2) - if instances[-1]['sensor']['data'][3]['SKkey']: instances[-1]['sensor']['data'][3]['object'] = AnalogIn(instances[-1]['object'], _A3) + for _ii in range(4): + if instances[-1]['sensor']['data'][_ii]['SKkey']: + _args = _ads_diff[_ii] if _ii in _ads_diff else (_ads_default_pins[_ii],) + instances[-1]['sensor']['data'][_ii]['object'] = AnalogIn(instances[-1]['object'], *_args) gain = 1 if 'sensorSettings' in instances[-1]['sensor']: @@ -768,4 +782,4 @@ def main(): if __name__ == '__main__': main() - \ No newline at end of file +