From 422b6f3ff2823ad9465449e99d69f75d2c728590 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 16 Oct 2026 05:03:22 -0700 Subject: [PATCH] Compare Sensor subscription deltas in exact LPP units --- examples/simple_sensor/SensorMesh.cpp | 23 +++- .../test_sensor_subscriptions.cpp | 130 ++++++++++++++++++ test/test_sensor_subscriptions.py | 35 +++++ 3 files changed, 183 insertions(+), 5 deletions(-) diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 02e08852d..f0694f6a0 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -132,6 +132,16 @@ static float findTelemValue(const uint8_t* buf, uint8_t size, uint8_t channel, u LPPData::getMultiplier(type), LPPData::isSigned(type)) : def_value; } +static int64_t readTelemRawValue(const uint8_t* data, uint8_t size, bool is_signed) { + if (!data || size == 0 || size > sizeof(uint32_t)) return 0; + int64_t value = 0; + for (uint8_t i = 0; i < size; ++i) value = (value << 8) | data[i]; + if (is_signed && (value & (int64_t(1) << (size * 8 - 1)))) { + value -= int64_t(1) << (size * 8); + } + return value; +} + /* ------------------ end Cayenne LPP helpers ----------------------*/ bool SensorMesh::telemHasChanged(ClientInfo* c) { @@ -147,11 +157,14 @@ bool SensorMesh::telemHasChanged(ClientInfo* c) { if (!LPPData::isScalarType(t) || sz > c->extra.sensor.min_deltas_len - i - 2) return false; - float min_delta = LPPData::getFloat(&c->extra.sensor.min_deltas[i + 2], sz, LPPData::getMultiplier(t), LPPData::isSigned(t)); - float pv = LPPData::getFloat(&c->extra.sensor.prev_telem[i + 2], sz, LPPData::getMultiplier(t), LPPData::isSigned(t)); - - float v = findTelemValue(buf, size, ch, t, 0.0f); - if (fabsf(v - pv) > min_delta) changed = true; + const bool is_signed = LPPData::isSigned(t); + const int64_t min_delta = readTelemRawValue(&c->extra.sensor.min_deltas[i + 2], sz, is_signed); + const int64_t pv = readTelemRawValue(&c->extra.sensor.prev_telem[i + 2], sz, is_signed); + const int64_t v = readTelemRawValue(findTelemData(buf, size, ch, t), sz, is_signed); + // The common LPP multiplier cancels. Compare raw units so float rounding + // cannot hide a one-LSB change or push an equal delta over the threshold. + const int64_t difference = v >= pv ? v - pv : pv - v; + if (difference > min_delta) changed = true; i += 2 + sz; // skip } diff --git a/test/fixtures/sensor_subscriptions/test_sensor_subscriptions.cpp b/test/fixtures/sensor_subscriptions/test_sensor_subscriptions.cpp index ff4c54764..435787804 100644 --- a/test/fixtures/sensor_subscriptions/test_sensor_subscriptions.cpp +++ b/test/fixtures/sensor_subscriptions/test_sensor_subscriptions.cpp @@ -323,6 +323,133 @@ static void snapshot_checks() { } std::cout << "Raw LPP snapshot samples checked: " << samples << '\n'; } +static void store_raw(uint8_t* data, uint8_t size, int64_t value) { + uint64_t raw = uint64_t(value); + for (unsigned i = size; i > 0; --i) { + data[i - 1] = uint8_t(raw); + raw >>= 8; + } +} +static void expect_delta(uint8_t type, int64_t previous, int64_t current, + int64_t threshold, bool expected) { + SensorMesh target; + ClientInfo client; + const uint8_t size = LPPData::getDataSize(type); + assert(subscribe(target, client, type, size) == 12); + store_raw(client.extra.sensor.min_deltas + 2, size, threshold); + target.telemetry.bytes.resize(2 + size); + target.telemetry.bytes[0] = 1; + target.telemetry.bytes[1] = type; + store_raw(target.telemetry.getBuffer() + 2, size, previous); + target.snapshotTelemetry(&client); + const auto baseline = target.telemetry.bytes; + store_raw(target.telemetry.getBuffer() + 2, size, current); + for (unsigned repeat = 0; repeat < 2; ++repeat) { + assert(target.telemHasChanged(&client) == expected + && "raw LPP delta must use the exact strict threshold"); + assert(!memcmp(client.extra.sensor.prev_telem, baseline.data(), baseline.size())); + } +} +static void delta_checks() { + for (unsigned type = 0; type < 256; ++type) { + if (!LPPData::isScalarType(type)) continue; + // Equal, smaller, and larger positive thresholds in both directions. + for (int64_t step : {0, 1, 2, 3}) { + expect_delta(type, 10, 10 + step, 2, step > 2); + expect_delta(type, 10 + step, 10, 2, step > 2); + expect_delta(type, 10, 10 + step, 0, step != 0); + } + const uint8_t size = LPPData::getDataSize(type); + if (LPPData::isSigned(type)) { + const int64_t minimum = -(int64_t(1) << (8 * size - 1)); + const int64_t maximum = -minimum - 1; + expect_delta(type, minimum, maximum, maximum, true); + expect_delta(type, maximum, minimum, maximum, true); + expect_delta(type, minimum, minimum + 1, 1, false); + expect_delta(type, -2, 1, 3, false); + expect_delta(type, 1, -2, 2, true); + // Negative signed thresholds retain their existing always-trigger behavior. + expect_delta(type, -2, -2, -1, true); + expect_delta(type, 0, 0, minimum, true); + } + SensorMesh target; + ClientInfo client; + assert(subscribe(target, client, type, size) == 12); + target.telemetry.bytes.resize(2 + size); + target.telemetry.bytes[0] = 1; + target.telemetry.bytes[1] = type; + store_raw(target.telemetry.getBuffer() + 2, size, 10); + target.snapshotTelemetry(&client); + store_raw(client.extra.sensor.min_deltas + 2, size, 10); + const auto complete = target.telemetry.bytes; + // Each truncated current record continues to use the zero default. + for (unsigned length = 0; length < complete.size(); ++length) { + target.telemetry.bytes.assign(complete.begin(), complete.begin() + length); + assert(!target.telemHasChanged(&client)); + store_raw(client.extra.sensor.min_deltas + 2, size, 9); + assert(target.telemHasChanged(&client)); + store_raw(client.extra.sensor.min_deltas + 2, size, 10); + } + target.telemetry.bytes = complete; + store_raw(client.extra.sensor.min_deltas + 2, size, -1); + client.extra.sensor.min_deltas_len = 1 + size; + assert(!target.telemHasChanged(&client)); // Truncated thresholds stay rejected. + } + // Ordinary scaled readings emitted exactly by the production scalar writer. + const struct { uint8_t type; int64_t previous, current; } ordinary[] = { + {LPP_CURRENT, 1234, 1235}, {LPP_TEMPERATURE, 201, 202} + }; + for (const auto& entry : ordinary) { + const uint32_t multiplier = LPPData::getMultiplier(entry.type); + for (int64_t raw : {entry.previous, entry.current}) { + uint8_t bytes[4] = {}; + LPPWriter writer(bytes, sizeof(bytes)); + assert(writer.writeData(1, entry.type, float(raw) / multiplier)); + assert((uint32_t(bytes[2]) * 256 + bytes[3]) == raw); + } + expect_delta(entry.type, entry.previous, entry.current, 1, false); + expect_delta(entry.type, entry.previous, entry.current + 1, 1, true); + } +} +static void wide_delta_checks() { + for (uint8_t type : {LPP_GENERIC_SENSOR, LPP_FREQUENCY, LPP_DISTANCE, LPP_ENERGY, LPP_UNIXTIME}) { + assert(LPPData::getDataSize(type) == 4 && !LPPData::isSigned(type)); + for (uint32_t raw : {0x1000000U, 0x7fffffffU, 1700000000U, 0xfffffffeU}) { + expect_delta(type, raw, uint64_t(raw) + 1, 0, true); + expect_delta(type, uint64_t(raw) + 1, raw, 0, true); + expect_delta(type, raw, uint64_t(raw) + 1, 1, false); + } + expect_delta(type, 16777219, 16777221, 1, true); + expect_delta(type, 16777221, 16777219, 1, true); + expect_delta(type, 0, UINT32_MAX, UINT32_MAX, false); + expect_delta(type, UINT32_MAX, 0, UINT32_MAX - 1, true); + expect_delta(type, UINT32_MAX, 1, UINT32_MAX - 1, false); + } + // Decoder sign extension covers every scalar width, including signed32. + for (uint8_t size = 1; size <= 4; ++size) { + const int64_t sign_bit = int64_t(1) << (8 * size - 1); + for (int64_t value : {-sign_bit, -sign_bit + 1, int64_t(-1), int64_t(0), sign_bit - 1}) { + uint8_t bytes[4] = {}; + store_raw(bytes, size, value); + assert(readTelemRawValue(bytes, size, true) == value); + } + } + assert(readTelemRawValue(nullptr, 4, false) == 0); + uint8_t bytes[5] = {}; + assert(readTelemRawValue(bytes, 0, false) == 0); + assert(readTelemRawValue(bytes, 5, false) == 0); +} +static void signed_wide_delta_checks() { + // The runner supplies signed metadata for this normally unsigned scalar. + const uint8_t type = LPP_GENERIC_SENSOR; + assert(LPPData::getDataSize(type) == 4 && LPPData::isSigned(type)); + expect_delta(type, INT32_MIN, INT32_MAX, INT32_MAX, true); + expect_delta(type, INT32_MAX, INT32_MIN, INT32_MAX, true); + expect_delta(type, INT32_MIN, INT32_MIN + 1, 0, true); + expect_delta(type, INT32_MAX - 1, INT32_MAX, 1, false); + expect_delta(type, -16777217, -16777216, 0, true); + expect_delta(type, 0, 0, INT32_MIN, true); +} static void writer_checks() { uint8_t buffer[16]; memset(buffer, 0xAA, sizeof(buffer)); @@ -432,6 +559,9 @@ int main(int argc, char** argv) { else if (!strcmp(argv[1], "writer")) writer_checks(); else if (!strcmp(argv[1], "tracker")) tracker_mode_checks(); else if (!strcmp(argv[1], "snapshot")) snapshot_checks(); + else if (!strcmp(argv[1], "delta")) delta_checks(); + else if (!strcmp(argv[1], "wide-delta")) wide_delta_checks(); + else if (!strcmp(argv[1], "signed-wide-delta")) signed_wide_delta_checks(); else assert(false); std::cout << "Sensor subscription checks passed\n"; } diff --git a/test/test_sensor_subscriptions.py b/test/test_sensor_subscriptions.py index 317703600..8b1f34b37 100644 --- a/test/test_sensor_subscriptions.py +++ b/test/test_sensor_subscriptions.py @@ -19,6 +19,7 @@ FIXTURE = ROOT / 'test/fixtures/sensor_subscriptions/test_sensor_subscriptions.c def production_methods(source): methods = extract_braced(source, 'static const uint8_t* findTelemData(') + '\n' methods += extract_braced(source, 'static float findTelemValue(') + '\n' + methods += extract_braced(source, 'static int64_t readTelemRawValue(') + '\n' methods += extract_braced(source, 'bool SensorMesh::telemHasChanged(') + '\n' if 'void SensorMesh::snapshotTelemetry(' in source: methods += extract_braced(source, 'void SensorMesh::snapshotTelemetry(') + '\n' @@ -78,6 +79,40 @@ class SensorSubscriptionTest(unittest.TestCase): def test_raw_snapshots_preserve_scalars_and_unchanged_pushes_stop(self): self.passed('snapshot') + def test_delta_threshold_boundaries_signed_values_and_missing_fields(self): + self.passed('delta') + + def test_full_width_four_byte_deltas_and_signed_decoder_extrema(self): + self.passed('wide-delta') + + def test_signed_four_byte_delta_arithmetic_without_narrowing(self): + def signed_scalar(helper): + original = 'return type == LPP_VOLTAGE || type == LPP_CURRENT' + self.assertEqual(helper.count(original), 1) + return helper.replace(original, 'return type == LPP_GENERIC_SENSOR || type == LPP_VOLTAGE || type == LPP_CURRENT', 1) + # Current four-byte types are unsigned. Exercise the same production + # arithmetic with signed metadata to check the full decoder range. + result = self.execute('signed-wide-delta', helper_transform=signed_scalar) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('Sensor subscription checks passed', result.stdout) + + def test_old_float_detector_misses_wide_changes_and_miscompares_equal_deltas(self): + def old(source): + begin = source.index(' const bool is_signed =', source.index('bool SensorMesh::telemHasChanged(')) + end = source.index('\n i += 2 + sz;', begin) + original = ''' float min_delta = LPPData::getFloat(&c->extra.sensor.min_deltas[i + 2], sz, LPPData::getMultiplier(t), LPPData::isSigned(t)); + float pv = LPPData::getFloat(&c->extra.sensor.prev_telem[i + 2], sz, LPPData::getMultiplier(t), LPPData::isSigned(t)); + + float v = findTelemValue(buf, size, ch, t, 0.0f); + if (fabsf(v - pv) > min_delta) changed = true; +''' + return source[:begin] + original + source[end:] + for case in ('delta', 'wide-delta'): + with self.subTest(case=case): + result = self.execute(case, old) + self.assertNotEqual(result.returncode, 0, 'float detector escaped regression') + self.assertIn('raw LPP delta must use the exact strict threshold', result.stderr) + def test_lossy_float_snapshot_negative_control(self): def old(source): current = ''' const uint8_t* value = findTelemData(buf, size, ch, t);