From df5e59131941549c54a6aec4ccb3ebeb91787394 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 6 Oct 2026 09:56:00 -0700 Subject: [PATCH] Bound telemetry replies and preserve Sensor send retries --- .github/workflows/run-unit-tests.yml | 2 + examples/simple_room_server/MyMesh.cpp | 6 +- examples/simple_sensor/SensorMesh.cpp | 78 ++-- examples/simple_sensor/SensorMesh.h | 1 + src/helpers/sensors/LPPDataHelpers.h | 113 ++++-- .../test_sensor_reply_bounds.cpp | 336 ++++++++++++++++ .../sensor_subscriptions/CAYENNE_LICENSE.txt | 19 + .../cayenne_lpp_1_6_1_metadata.inc | 370 ++++++++++++++++++ .../test_sensor_subscriptions.cpp | 323 +++++++++++++++ test/test_sensor_reply_bounds.py | 156 ++++++++ test/test_sensor_subscriptions.py | 184 +++++++++ 11 files changed, 1522 insertions(+), 66 deletions(-) create mode 100644 test/fixtures/sensor_reply_bounds/test_sensor_reply_bounds.cpp create mode 100644 test/fixtures/sensor_subscriptions/CAYENNE_LICENSE.txt create mode 100644 test/fixtures/sensor_subscriptions/cayenne_lpp_1_6_1_metadata.inc create mode 100644 test/fixtures/sensor_subscriptions/test_sensor_subscriptions.cpp create mode 100644 test/test_sensor_reply_bounds.py create mode 100644 test/test_sensor_subscriptions.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index e6d4c65e0..cd7a24dab 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -288,6 +288,8 @@ jobs: python3 -B test/test_gps_sync_preferences.py -v python3 -B test/test_t1000e_sensor_power.py -v python3 -B test/test_sensor_battery_alerts.py -v + python3 -B test/test_sensor_subscriptions.py -v + python3 -B test/test_sensor_reply_bounds.py -v - name: Verify Companion ASCII defaults and USB session cleanup run: | diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 64caf2b34..bc560eec8 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -268,8 +269,9 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t telemetry.addTemperature(TELEM_CHANNEL_SELF, temperature); // Built-in MCU Temperature } - uint8_t tlen = telemetry.getSize(); - memcpy(&reply_data[4], telemetry.getBuffer(), tlen); + const uint8_t* tbuf = telemetry.getBuffer(); + const size_t tlen = LPPData::boundedPrefix(tbuf, telemetry.getSize(), reply_capacity - 4); + memcpy(&reply_data[4], tbuf, tlen); return 4 + tlen; // reply_len } if (payload[0] == REQ_TYPE_GET_ACCESS_LIST && sender->isAdmin()) { diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index bfdf34291..a06fe109d 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -136,7 +136,8 @@ bool SensorMesh::telemHasChanged(ClientInfo* c) { uint8_t ch = c->extra.sensor.min_deltas[i]; // Get channel # uint8_t t = c->extra.sensor.min_deltas[i + 1]; // Get data type uint8_t sz = LPPData::getDataSize(t); - if (sz > c->extra.sensor.min_deltas_len - i - 2) return false; + 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)); @@ -146,27 +147,30 @@ bool SensorMesh::telemHasChanged(ClientInfo* c) { i += 2 + sz; // skip } - if (changed) { - // take snapshot of all _monitored_ telem values, for next cycle - i = 0; - while (i + 2 < c->extra.sensor.min_deltas_len) { - uint8_t ch = c->extra.sensor.min_deltas[i]; // Get channel # - uint8_t t = c->extra.sensor.min_deltas[i + 1]; // Get data type - uint8_t sz = LPPData::getDataSize(t); - if (sz > c->extra.sensor.min_deltas_len - i - 2) return false; - - c->extra.sensor.prev_telem[i] = ch; - c->extra.sensor.prev_telem[i + 1] = t; - - float v = findTelemValue(buf, size, ch, t, 0.0f); - LPPData::putFloat(&c->extra.sensor.prev_telem[i + 2], v, sz, LPPData::getMultiplier(t), LPPData::isSigned(t)); - - i += 2 + sz; // skip - } - } return changed; } +void SensorMesh::snapshotTelemetry(ClientInfo* c) { + // Only advance thresholds after the corresponding push is queued. + auto buf = telemetry.getBuffer(); + uint8_t size = telemetry.getSize(); + uint8_t i = 0; + while (i + 2 < c->extra.sensor.min_deltas_len) { + uint8_t ch = c->extra.sensor.min_deltas[i]; + uint8_t t = c->extra.sensor.min_deltas[i + 1]; + uint8_t sz = LPPData::getDataSize(t); + if (!LPPData::isScalarType(t) + || sz > c->extra.sensor.min_deltas_len - i - 2) return; + + c->extra.sensor.prev_telem[i] = ch; + c->extra.sensor.prev_telem[i + 1] = t; + float v = findTelemValue(buf, size, ch, t, 0.0f); + LPPData::putFloat(&c->extra.sensor.prev_telem[i + 2], v, sz, + LPPData::getMultiplier(t), LPPData::isSigned(t)); + i += 2 + sz; + } +} + static uint8_t getTelemetryPermissions(uint8_t acl_perms, uint8_t telemetry_access) { if (telemetry_access == TELEMETRY_ACCESS_ALL) { return TELEM_PERM_BASE | TELEM_PERM_LOCATION | TELEM_PERM_ENVIRONMENT; @@ -228,12 +232,14 @@ uint8_t SensorMesh::handleRequest(ClientInfo* from, uint32_t sender_timestamp, } sensors.querySensors(telemetry_permissions, telemetry); // TODO: let requester know permissions they have: telemetry.addPresence(TELEM_CHANNEL_SELF, perms); - uint8_t tlen = telemetry.getSize(); - memcpy(&reply_data[4], telemetry.getBuffer(), tlen); + const uint8_t* tbuf = telemetry.getBuffer(); + const size_t tlen = LPPData::boundedPrefix(tbuf, telemetry.getSize(), reply_capacity - 4); + memcpy(&reply_data[4], tbuf, tlen); return 4 + tlen; // reply_len } if (req_type == REQ_TYPE_GET_AVG_MIN_MAX && payload_len >= 10 && (perms & PERM_ACL_ROLE_MASK) >= PERM_ACL_READ_ONLY) { + if (reply_capacity < 8) return 0; // tag and current timestamp uint32_t start_secs_ago, end_secs_ago; memcpy(&start_secs_ago, &payload[0], 4); memcpy(&end_secs_ago, &payload[4], 4); @@ -248,7 +254,8 @@ uint8_t SensorMesh::handleRequest(ClientInfo* from, uint32_t sender_timestamp, n = 0; } - uint8_t ofs = 4; + if (n < 0 || n > int(sizeof(data) / sizeof(data[0]))) return 0; + size_t ofs = 4; { uint32_t now = getRTCClock()->getCurrentTime(); memcpy(&reply_data[ofs], &now, 4); ofs += 4; @@ -256,9 +263,13 @@ uint8_t SensorMesh::handleRequest(ClientInfo* from, uint32_t sender_timestamp, for (int i = 0; i < n; i++) { auto d = &data[i]; + if (!LPPData::isScalarType(d->_lpp_type) + || !isfinite(d->_min) || !isfinite(d->_max) || !isfinite(d->_avg)) continue; + const uint8_t sz = LPPData::getDataSize(d->_lpp_type); + const size_t item_len = 2 + 3 * sz; + if (item_len > reply_capacity - ofs) break; reply_data[ofs++] = d->_channel; reply_data[ofs++] = d->_lpp_type; - uint8_t sz = LPPData::getDataSize(d->_lpp_type); uint32_t mult = LPPData::getMultiplier(d->_lpp_type); bool is_signed = LPPData::isSigned(d->_lpp_type); ofs += LPPData::putFloat(&reply_data[ofs], d->_min, sz, mult, is_signed); @@ -284,7 +295,6 @@ uint8_t SensorMesh::handleRequest(ClientInfo* from, uint32_t sender_timestamp, } } if (req_type == REQ_TYPE_SUBSCRIBE && payload_len >= 8 && (perms & PERM_ACL_ROLE_MASK) >= PERM_ACL_READ_ONLY) { - memcpy(&from->extra.sensor.push_tag, &payload[0], 4); uint16_t timeout_secs; memcpy(&timeout_secs, &payload[4], 2); const uint8_t min_deltas_len = payload[7]; @@ -303,11 +313,14 @@ uint8_t SensorMesh::handleRequest(ClientInfo* from, uint32_t sender_timestamp, valid_deltas = false; break; } - const size_t item_len = 2 + LPPData::getDataSize(payload[8 + i + 1]); - valid_deltas = item_len <= min_deltas_len - i; + const uint8_t type = payload[8 + i + 1]; + const size_t item_len = 2 + LPPData::getDataSize(type); + valid_deltas = LPPData::isScalarType(type) + && item_len <= min_deltas_len - i; i += item_len; } if (r && valid_deltas) { + memcpy(&from->extra.sensor.push_tag, &payload[0], 4); from->extra.sensor.scope_region_id = r->id; from->extra.sensor.expiry_timestamp = getRTCClock()->getCurrentTime() + timeout_secs; from->extra.sensor.min_deltas_len = min_deltas_len; @@ -1059,7 +1072,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise region_map(key_store), temp_map(key_store), _cli(board, rtc, sensors, region_map, acl, &_prefs, this), _clock_sync(radio, ms, rtc, acl, sensors, _prefs.tx_delay_factor, this), - telemetry(MAX_PACKET_PAYLOAD - 4) + telemetry(mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY - 8) { next_local_advert = next_flood_advert = 0; dirty_contacts_expiry = 0; @@ -1446,7 +1459,7 @@ void SensorMesh::loop() { TransportKey scope; if (region_map.getTransportKeysFor(*region, &scope, 1) == 0) continue; const uint8_t telemetry_len = telemetry.getSize(); - if (telemetry_len > sizeof(reply_data) - 8) continue; + if (telemetry_len > mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY - 8) continue; memcpy(reply_data, &client->extra.sensor.push_tag, 4); const uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); memcpy(&reply_data[4], ×tamp, 4); @@ -1455,11 +1468,10 @@ void SensorMesh::loop() { PAYLOAD_TYPE_RESPONSE, client->id, client->shared_secret, reply_data, 8 + telemetry_len); if (reply) { - if (mesh::Packet::isValidPathLen(client->out_path_len)) { - sendDirect(reply, client->out_path, client->out_path_len, 0); - } else { - sendFloodScoped(scope, reply, 0, _prefs.path_hash_mode + 1); - } + const bool queued = mesh::Packet::isValidPathLen(client->out_path_len) + ? sendDirect(reply, client->out_path, client->out_path_len, 0) + : sendFloodScoped(scope, reply, 0, _prefs.path_hash_mode + 1); + if (queued) snapshotTelemetry(client); } } diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 17ba40df4..82db85aba 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -241,6 +241,7 @@ private: bool region_load_active; bool telemHasChanged(ClientInfo* c); + void snapshotTelemetry(ClientInfo* c); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); uint8_t handleRequest(ClientInfo* from, uint32_t sender_timestamp, uint8_t req_type, uint8_t* payload, size_t payload_len, diff --git a/src/helpers/sensors/LPPDataHelpers.h b/src/helpers/sensors/LPPDataHelpers.h index 1048b11f5..57c3fbf99 100644 --- a/src/helpers/sensors/LPPDataHelpers.h +++ b/src/helpers/sensors/LPPDataHelpers.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #define LPP_DIGITAL_INPUT 0 // 1 byte #define LPP_DIGITAL_OUTPUT 1 // 1 byte @@ -13,8 +15,8 @@ #define LPP_RELATIVE_HUMIDITY 104 // 1 byte, 0.5% unsigned #define LPP_ACCELEROMETER 113 // 2 bytes per axis, 0.001G #define LPP_BAROMETRIC_PRESSURE 115 // 2 bytes 0.1hPa unsigned -#define LPP_VOLTAGE 116 // 2 bytes 0.01V unsigned -#define LPP_CURRENT 117 // 2 bytes 0.001A unsigned +#define LPP_VOLTAGE 116 // 2 bytes 0.01V signed +#define LPP_CURRENT 117 // 2 bytes 0.001A signed #define LPP_FREQUENCY 118 // 4 bytes 1Hz unsigned #define LPP_PERCENTAGE 120 // 1 byte 1-100% unsigned #define LPP_ALTITUDE 121 // 2 byte 1m signed @@ -65,6 +67,53 @@ class LPPData { public: + // Delta subscriptions and history records encode one scalar, not vectors. + static bool isScalarType(uint8_t type) { + switch (type) { + case LPP_DIGITAL_INPUT: + case LPP_DIGITAL_OUTPUT: + case LPP_ANALOG_INPUT: + case LPP_ANALOG_OUTPUT: + case LPP_GENERIC_SENSOR: + case LPP_LUMINOSITY: + case LPP_PRESENCE: + case LPP_TEMPERATURE: + case LPP_RELATIVE_HUMIDITY: + case LPP_BAROMETRIC_PRESSURE: + case LPP_VOLTAGE: + case LPP_CURRENT: + case LPP_FREQUENCY: + case LPP_PERCENTAGE: + case LPP_ALTITUDE: + case LPP_CONCENTRATION: + case LPP_POWER: + case LPP_DISTANCE: + case LPP_ENERGY: + case LPP_DIRECTION: + case LPP_UNIXTIME: + case LPP_SWITCH: + return true; + default: + return false; + } + } + + // Keep complete known LPP entries within the supplied payload budget. + static size_t boundedPrefix(const uint8_t* buf, size_t size, size_t capacity) { + if (buf == NULL) return 0; + size_t pos = 0; + while (pos + 2 <= size) { + if (buf[pos] == 0) break; // channel zero is end-of-data + const uint8_t type = buf[pos + 1]; + if (!LPPData::isScalarType(type) && type != LPP_GPS + && type != LPP_ACCELEROMETER && type != LPP_GYROMETER && type != LPP_COLOUR) break; + const size_t item_len = 2 + LPPData::getDataSize(type); + if (item_len > size - pos || item_len > capacity - pos) break; + pos += item_len; + } + return pos; + } + static uint8_t getDataSize(uint8_t type) { switch (type) { case LPP_GPS: @@ -88,7 +137,6 @@ public: case LPP_TEMPERATURE: case LPP_CONCENTRATION: case LPP_BAROMETRIC_PRESSURE: - case LPP_RELATIVE_HUMIDITY: case LPP_ALTITUDE: case LPP_VOLTAGE: case LPP_CURRENT: @@ -109,20 +157,23 @@ public: case LPP_ANALOG_INPUT: case LPP_ANALOG_OUTPUT: return 100; + case LPP_RELATIVE_HUMIDITY: + return 2; case LPP_TEMPERATURE: case LPP_BAROMETRIC_PRESSURE: - case LPP_RELATIVE_HUMIDITY: return 10; } return 1; } static bool isSigned(uint8_t type) { - return type == LPP_ALTITUDE || type == LPP_TEMPERATURE || type == LPP_GYROMETER || - type == LPP_ANALOG_INPUT || type == LPP_ANALOG_OUTPUT || type == LPP_GPS || type == LPP_ACCELEROMETER; + return type == LPP_VOLTAGE || type == LPP_CURRENT || type == LPP_ALTITUDE || + type == LPP_TEMPERATURE || type == LPP_GYROMETER || type == LPP_ANALOG_INPUT || + type == LPP_ANALOG_OUTPUT || type == LPP_GPS || type == LPP_ACCELEROMETER; } static float getFloat(const uint8_t * buffer, uint8_t size, uint32_t multiplier, bool is_signed) { + if (size == 0 || size > sizeof(uint32_t) || multiplier == 0) return 0.0f; uint32_t value = 0; for (uint8_t i = 0; i < size; i++) { value = (value << 8) + buffer[i]; @@ -130,7 +181,7 @@ public: int sign = 1; if (is_signed) { - uint32_t bit = 1ul << ((size * 8) - 1); + uint32_t bit = uint32_t(1) << ((size * 8) - 1); if ((value & bit) == bit) { value = (bit << 1) - value; sign = -1; @@ -140,18 +191,24 @@ public: } static uint8_t putFloat(uint8_t * dest, float value, uint8_t size, uint32_t multiplier, bool is_signed) { - // check sign - bool sign = value < 0; - if (sign) value = -value; + if (size == 0 || size > sizeof(uint32_t) || multiplier == 0 + || !isfinite(value)) return 0; - // get value to store - uint32_t v = value * multiplier; - - // format an uint32_t as if it was an int32_t - if (is_signed & sign) { - uint32_t mask = (1 << (size * 8)) - 1; - v = v & mask; - if (sign) v = mask - v + 1; + const uint32_t mask = size == sizeof(uint32_t) + ? UINT32_MAX : (uint32_t(1) << (size * 8)) - 1; + // Preserve the existing float scaling before checking integer limits. + const double scaled = (double)(value * multiplier); + uint32_t v; + if (is_signed) { + const bool negative = scaled < 0; + const uint32_t sign_bit = uint32_t(1) << ((size * 8) - 1); + const uint32_t limit = negative ? sign_bit : sign_bit - 1; + const double magnitude = negative ? -scaled : scaled; + v = magnitude >= (double)limit ? limit : (uint32_t)magnitude; + if (negative) v = (uint32_t(0) - v) & mask; + } else { + // float(UINT32_MAX) rounds to 2^32; clamp before the integer cast. + v = scaled <= 0.0 ? 0 : scaled >= (double)mask ? mask : (uint32_t)scaled; } // add bytes (MSB first) @@ -193,7 +250,7 @@ public: return _pos <= _len; } bool readVoltage(float& voltage) { - voltage = LPPData::getFloat(&_buf[_pos], 2, 100, false); _pos += 2; + voltage = LPPData::getFloat(&_buf[_pos], 2, 100, true); _pos += 2; return _pos <= _len; } bool readCurrent(float& amps) { @@ -243,24 +300,18 @@ public: uint8_t sz = LPPData::getDataSize(type); bool s = LPPData::isSigned(type); uint32_t mul = LPPData::getMultiplier(type); - if (_len + 2 + sz <= _max_len) { - _buf[_len++] = channel; - _buf[_len++] = type; - _len += LPPData::putFloat(&_buf[_len], v, sz, mul, s); + if (LPPData::isScalarType(type) && _len + 2 + sz <= _max_len) { + if (LPPData::putFloat(&_buf[_len + 2], v, sz, mul, s) != sz) return false; + _buf[_len] = channel; + _buf[_len + 1] = type; + _len += 2 + sz; return true; } return false; } bool writeVoltage(uint8_t channel, float voltage) { - if (_len + 4 <= _max_len) { - _buf[_len++] = channel; - _buf[_len++] = LPP_VOLTAGE; - uint16_t value = voltage * 100; - write(value); - return true; - } - return false; + return writeData(channel, LPP_VOLTAGE, voltage); } bool writeGPS(uint8_t channel, float lat, float lon, float alt) { diff --git a/test/fixtures/sensor_reply_bounds/test_sensor_reply_bounds.cpp b/test/fixtures/sensor_reply_bounds/test_sensor_reply_bounds.cpp new file mode 100644 index 000000000..f1ec48436 --- /dev/null +++ b/test/fixtures/sensor_reply_bounds/test_sensor_reply_bounds.cpp @@ -0,0 +1,336 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "telemetry_capacity.inc" +#define REQ_TYPE_GET_TELEMETRY_DATA SENSOR_REQ_TYPE_GET_TELEMETRY_DATA +#define PERM_ACL_GUEST 0 +#define REQ_TYPE_GET_AVG_MIN_MAX 0x04 +#define PERM_ACL_ROLE_MASK 7 +#define PERM_ACL_READ_ONLY 1 +#define TELEMETRY_ACCESS_ALL 2 +#define TELEM_PERM_BASE 1 +#define TELEM_PERM_LOCATION 2 +#define TELEM_PERM_ENVIRONMENT 4 +#define TELEM_CHANNEL_SELF 1 +#define GPS_READ_INTERVAL_SECS 60 + +namespace mesh { +struct Identity { + uint8_t pub_key[PUB_KEY_SIZE] = {}; + size_t copyHashTo(uint8_t* destination) const { *destination = 0x42; return PATH_HASH_SIZE; } +}; +struct Utils { + // The production packet builders retain their exact admission and layout. + // Cryptography alone is replaced with deterministic padded plaintext. + static int encryptThenMAC(const uint8_t*, uint8_t* destination, + const uint8_t* source, int length) { + const int encrypted = (length + CIPHER_BLOCK_SIZE - 1) / CIPHER_BLOCK_SIZE * CIPHER_BLOCK_SIZE; + memset(destination, 0, encrypted + CIPHER_MAC_SIZE); + memcpy(destination + CIPHER_MAC_SIZE, source, length); + return encrypted + CIPHER_MAC_SIZE; + } +}; +struct Random { void random(uint8_t* data, size_t length) { memset(data, 0x99, length); } }; +class Mesh { + std::vector allocations; +public: + Identity self_id; + Random rng; + ~Mesh() { for (auto* packet : allocations) delete packet; } + Packet* obtainNewPacket() { auto* packet = new Packet; allocations.push_back(packet); return packet; } + uint8_t getContactTxRadio(const Identity&) { return RADIO_TX_AUTO; } + Random* getRNG() { return &rng; } + Packet* createPathReturn(const Identity&, const uint8_t*, const uint8_t*, uint8_t, + uint8_t, const uint8_t*, size_t); + Packet* createPathReturn(const uint8_t*, const uint8_t*, const uint8_t*, uint8_t, + uint8_t, const uint8_t*, size_t); + Packet* createDatagram(uint8_t, const Identity&, const uint8_t*, const uint8_t*, size_t); +}; +} +#include "history_types.inc" +struct ClientInfo { + mesh::Identity id; + uint8_t permissions = 3; + bool isAdmin() const { return (permissions & PERM_ACL_ROLE_MASK) == 3; } +}; +struct Clock : mesh::RTCClock { + uint32_t getCurrentTime() override { return 1000; } + void setCurrentTime(uint32_t) override {} +}; +// Sensor and telemetry collection boundaries are modeled; handler and LPP +// sizing/conversion, real history aggregation, and packet assembly are actual. +struct Telemetry { + std::vector data; + size_t max_len = SENSOR_TELEMETRY_CAPACITY; + Telemetry() { data.reserve(max_len); } + void reset() { data.clear(); } + uint8_t getSize() const { return data.size(); } + uint8_t* getBuffer() { return data.data(); } + void add(uint8_t channel, uint8_t type, size_t size) { + if (data.size() + 2 + size > max_len) return; + data.push_back(channel); data.push_back(type); + for (size_t i = 0; i < size; ++i) data.push_back(0x22); + } + void addVoltage(uint8_t channel, float) { add(channel, LPP_VOLTAGE, 2); } + void addTemperature(uint8_t channel, float) { add(channel, LPP_TEMPERATURE, 2); } +}; +struct Sensors { + std::vector body; + void requestGpsTelemetryTimeSync(uint32_t) {} + void querySensors(uint8_t, Telemetry& telemetry) { + if (telemetry.data.size() + body.size() <= telemetry.max_len) + telemetry.data.insert(telemetry.data.end(), body.begin(), body.end()); + } +}; +struct Board { + uint16_t getBattMilliVolts() { return 3500; } + float getMCUTemperature() { return NAN; } +}; +class SensorMesh : public mesh::Mesh { +public: + uint8_t reply_data[MAX_PACKET_PAYLOAD] = {}; + struct { uint8_t telemetry_access = TELEMETRY_ACCESS_ALL; } _prefs; + Telemetry telemetry; + Sensors sensors; + Board board; + Clock clock; + std::array history; + int history_count = 8; + bool empty_real_series = false; + Clock* getRTCClock() { return &clock; } + SensorMesh() { + for (size_t i = 0; i < history.size(); ++i) + history[i] = {1.0f, 2.0f, 1.5f, LPP_GENERIC_SENSOR, uint8_t(i + 1)}; + } + int querySeriesData(uint32_t start, uint32_t end, MinMaxAvg* destination, int max_num) { + assert(max_num == 8); + if (empty_real_series) { + float values[4]; + TimeSeriesData series(values, 4, 60); + series.calcMinMaxAvg(&clock, start, end, destination, TELEM_CHANNEL_SELF, LPP_VOLTAGE); + return 1; + } + for (int i = 0; i < std::min(max_num, std::max(0, history_count)); ++i) destination[i] = history[i]; + return history_count; + } + uint8_t handleRequest(ClientInfo*, uint32_t, uint8_t, uint8_t*, size_t, + size_t = mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY); +}; +class MyMesh : public SensorMesh { +public: + MyMesh() { telemetry.max_len = ROOM_TELEMETRY_CAPACITY; telemetry.data.reserve(telemetry.max_len); } + int handleRequest(ClientInfo*, uint32_t, uint8_t*, size_t, + size_t = mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY); +}; +#include "production.inc" + +static std::vector entry(uint8_t type, unsigned size, uint8_t channel = 2) { + std::vector body{channel, type}; + body.insert(body.end(), size, 0x33); + return body; +} +static void verify_packet(SensorMesh& target, ClientInfo& client, unsigned length, + bool flood, uint8_t encoded_path) { + uint8_t secret[PUB_KEY_SIZE] = {}, path[MAX_PATH_SIZE] = {}; + auto* packet = flood ? target.createPathReturn(client.id, secret, path, encoded_path, + PAYLOAD_TYPE_RESPONSE, target.reply_data, length) + : target.createDatagram(PAYLOAD_TYPE_RESPONSE, client.id, secret, + target.reply_data, length); + assert(packet && packet->payload_len <= MAX_PACKET_PAYLOAD); + const unsigned path_bytes = flood ? (encoded_path & 63) * ((encoded_path >> 6) + 1) : 0; + const unsigned decoded_size = packet->payload_len - 2 * PATH_HASH_SIZE - CIPHER_MAC_SIZE + - (flood ? path_bytes + 2 : 0); + assert(decoded_size + 2 <= MAX_FRAME_SIZE); +} +static void telemetry_checks() { + ClientInfo client; + uint8_t mask = 0; + for (bool flood : {false, true}) { + for (unsigned path = 0; path < 256; ++path) { + if (!mesh::Packet::isValidPathLen(path)) continue; + const size_t capacity = std::min(mesh::clientACLReplyCapacity(flood, path), + mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY); + SensorMesh target; + for (unsigned i = 0; i < SENSOR_TELEMETRY_CAPACITY / 3; ++i) { + auto item = entry(LPP_RELATIVE_HUMIDITY, 1, i + 2); + target.sensors.body.insert(target.sensors.body.end(), item.begin(), item.end()); + } + // Suppress self voltage to fill the actual capacity with whole humidity records. + mask = TELEM_PERM_BASE; + const unsigned length = target.handleRequest(&client, 51, REQ_TYPE_GET_TELEMETRY_DATA, + &mask, 1, capacity); + assert(length <= capacity && length >= 4 && (length - 4) % 3 == 0); + assert(length == 4 + std::min(size_t(SENSOR_TELEMETRY_CAPACITY / 3), (capacity - 4) / 3) * 3); + assert(!memcmp(target.reply_data + 4, target.sensors.body.data(), length - 4)); + verify_packet(target, client, length, flood, path); + } + } + for (uint8_t type : {uint8_t(LPP_GPS), uint8_t(LPP_ACCELEROMETER), uint8_t(LPP_GYROMETER), uint8_t(LPP_COLOUR)}) { + SensorMesh target; + target.sensors.body = entry(type, LPPData::getDataSize(type)); + mask = TELEM_PERM_BASE; + const unsigned full = 4 + target.sensors.body.size(); + assert(target.handleRequest(&client, 51, REQ_TYPE_GET_TELEMETRY_DATA, &mask, 1, full) == full); + assert(!memcmp(target.reply_data + 4, target.sensors.body.data(), full - 4)); + assert(target.handleRequest(&client, 51, REQ_TYPE_GET_TELEMETRY_DATA, &mask, 1, full - 1) == 4); + } + { + SensorMesh target; + mask = 0xFF; + assert(target.handleRequest(&client, 51, REQ_TYPE_GET_TELEMETRY_DATA, &mask, 1) == 4); + mask = TELEM_PERM_BASE; + } + for (unsigned capacity = 0; capacity < 4; ++capacity) { + SensorMesh target; + assert(target.handleRequest(&client, 51, REQ_TYPE_GET_TELEMETRY_DATA, &mask, 1, capacity) == 0); + } + for (auto invalid : {std::vector{2}, std::vector{2, LPP_GPS, 3}, + entry(0xFF, 1), entry(LPP_POLYLINE, 8), entry(LPP_VOLTAGE, 2, 0)}) { + SensorMesh target; + target.sensors.body = invalid; + assert(target.handleRequest(&client, 51, REQ_TYPE_GET_TELEMETRY_DATA, &mask, 1) == 4); + } +} +static void room_telemetry_checks() { + ClientInfo client; + uint8_t query[] = {ROOM_REQ_TYPE_GET_TELEMETRY_DATA, 0}; + for (bool flood : {false, true}) { + for (unsigned path = 0; path < 256; ++path) { + if (!mesh::Packet::isValidPathLen(path)) continue; + const size_t capacity = std::min(mesh::clientACLReplyCapacity(flood, path), + mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY); + MyMesh target; + for (unsigned i = 0; i < ROOM_TELEMETRY_CAPACITY / 4 - 1; ++i) { + auto item = entry(LPP_VOLTAGE, 2, i + 2); + target.sensors.body.insert(target.sensors.body.end(), item.begin(), item.end()); + } + const unsigned length = target.handleRequest(&client, 51, query, sizeof(query), capacity); + assert(length == 4 + std::min(size_t(ROOM_TELEMETRY_CAPACITY / 4), (capacity - 4) / 4) * 4); + assert(length <= capacity && (length - 4) % 4 == 0); + assert(!memcmp(target.reply_data + 4, target.telemetry.getBuffer(), length - 4)); + verify_packet(target, client, length, flood, path); + } + } + for (uint8_t type : {uint8_t(LPP_GPS), uint8_t(LPP_ACCELEROMETER), uint8_t(LPP_GYROMETER), uint8_t(LPP_COLOUR)}) { + MyMesh target; + target.sensors.body = entry(type, LPPData::getDataSize(type)); + const unsigned full = 8 + target.sensors.body.size(); + assert(target.handleRequest(&client, 51, query, sizeof(query), full) == int(full)); + assert(!memcmp(target.reply_data + 8, target.sensors.body.data(), full - 8)); + assert(target.handleRequest(&client, 51, query, sizeof(query), full - 1) == 8); + } + for (unsigned capacity = 0; capacity < 4; ++capacity) { + MyMesh target; + assert(target.handleRequest(&client, 51, query, sizeof(query), capacity) == 0); + } + for (auto invalid : {std::vector{2}, std::vector{2, LPP_GPS, 3}, + entry(0xFF, 1), entry(LPP_POLYLINE, 8), entry(LPP_VOLTAGE, 2, 0)}) { + MyMesh target; + target.sensors.body = invalid; + assert(target.handleRequest(&client, 51, query, sizeof(query)) == 8); + } + MyMesh target; + assert(target.handleRequest(&client, 51, query, 1) == 0); + assert(target.handleRequest(&client, 51, query, 0) == 0); + assert(target.handleRequest(&client, 51, nullptr, 2) == 0); + assert(target.handleRequest(nullptr, 51, query, sizeof(query)) == 0); +} + +static void history_checks() { + ClientInfo client; + uint8_t query[10] = {}; + for (bool flood : {false, true}) { + for (unsigned path = 0; path < 256; ++path) { + if (!mesh::Packet::isValidPathLen(path)) continue; + const size_t capacity = std::min(mesh::clientACLReplyCapacity(flood, path), + mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY); + SensorMesh target; + const unsigned length = target.handleRequest(&client, 51, REQ_TYPE_GET_AVG_MIN_MAX, + query, sizeof(query), capacity); + assert(length == 8 + std::min(size_t(8), (capacity - 8) / 14) * 14); + uint32_t tag, now; + memcpy(&tag, target.reply_data, 4); memcpy(&now, target.reply_data + 4, 4); + assert(tag == 51 && now == 1000); + for (unsigned i = 8, channel = 1; i < length; i += 14, ++channel) { + assert(target.reply_data[i] == channel && target.reply_data[i + 1] == LPP_GENERIC_SENSOR); + assert(LPPData::getFloat(target.reply_data + i + 2, 4, 1, false) == 1); + assert(LPPData::getFloat(target.reply_data + i + 6, 4, 1, false) == 2); + assert(LPPData::getFloat(target.reply_data + i + 10, 4, 1, false) == 1); + } + verify_packet(target, client, length, flood, path); + } + } + for (unsigned type = 0; type < 256; ++type) { + if (!LPPData::isScalarType(type)) continue; + SensorMesh target; + target.history_count = 1; + target.history[0]._lpp_type = type; + const unsigned length = target.handleRequest(&client, 51, REQ_TYPE_GET_AVG_MIN_MAX, query, sizeof(query)); + assert(length == 8u + 2u + 3u * LPPData::getDataSize(type)); + assert(target.reply_data[8] == 1 && target.reply_data[9] == type); + assert(LPPData::getFloat(target.reply_data + 10, LPPData::getDataSize(type), + LPPData::getMultiplier(type), LPPData::isSigned(type)) == 1.0f); + verify_packet(target, client, length, false, 0); + } + for (uint8_t type : {uint8_t(LPP_GPS), uint8_t(LPP_ACCELEROMETER), uint8_t(LPP_GYROMETER), + uint8_t(LPP_COLOUR), uint8_t(LPP_POLYLINE), uint8_t(0xFF)}) { + SensorMesh target; + target.history_count = 8; + for (auto& record : target.history) record._lpp_type = type; + assert(target.handleRequest(&client, 51, REQ_TYPE_GET_AVG_MIN_MAX, query, sizeof(query)) == 8); + } + for (int count : {-1, 9, 100}) { + SensorMesh target; + target.history_count = count; + assert(target.handleRequest(&client, 51, REQ_TYPE_GET_AVG_MIN_MAX, query, sizeof(query)) == 0); + } + for (unsigned capacity = 0; capacity < 8; ++capacity) { + SensorMesh target; + assert(target.handleRequest(&client, 51, REQ_TYPE_GET_AVG_MIN_MAX, query, sizeof(query), capacity) == 0); + } + { + SensorMesh target; + target.empty_real_series = true; + // The actual history aggregator returns NaN for an empty query range. + assert(target.handleRequest(&client, 51, REQ_TYPE_GET_AVG_MIN_MAX, query, sizeof(query)) == 8); + } + for (float value : {NAN, INFINITY, -INFINITY}) { + SensorMesh target; + target.history_count = 1; + target.history[0]._avg = value; + assert(target.handleRequest(&client, 51, REQ_TYPE_GET_AVG_MIN_MAX, query, sizeof(query)) == 8); + } +} +int main(int argc, char** argv) { + assert(argc == 2); + if (!strcmp(argv[1], "room")) room_telemetry_checks(); + else if (!strcmp(argv[1], "telemetry")) telemetry_checks(); + else if (!strcmp(argv[1], "history")) history_checks(); + else if (!strcmp(argv[1], "empty")) { + SensorMesh target; + ClientInfo client; + uint8_t query[10] = {}; + target.empty_real_series = true; + assert(target.handleRequest(&client, 51, REQ_TYPE_GET_AVG_MIN_MAX, query, sizeof(query)) == 8); + } + else if (!strcmp(argv[1], "packet")) { + SensorMesh target; + ClientInfo client; + for (unsigned i = 0; i < SENSOR_TELEMETRY_CAPACITY / 4; ++i) { + auto item = entry(LPP_VOLTAGE, 2, i + 2); + target.sensors.body.insert(target.sensors.body.end(), item.begin(), item.end()); + } + uint8_t mask = TELEM_PERM_BASE; + const unsigned length = target.handleRequest(&client, 51, REQ_TYPE_GET_TELEMETRY_DATA, &mask, 1); + verify_packet(target, client, length, false, 0); + } + else assert(false); + std::cout << "Sensor response bounds and packet admission passed\n"; +} diff --git a/test/fixtures/sensor_subscriptions/CAYENNE_LICENSE.txt b/test/fixtures/sensor_subscriptions/CAYENNE_LICENSE.txt new file mode 100644 index 000000000..d13cc4b26 --- /dev/null +++ b/test/fixtures/sensor_subscriptions/CAYENNE_LICENSE.txt @@ -0,0 +1,19 @@ +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/test/fixtures/sensor_subscriptions/cayenne_lpp_1_6_1_metadata.inc b/test/fixtures/sensor_subscriptions/cayenne_lpp_1_6_1_metadata.inc new file mode 100644 index 000000000..8a5c62d82 --- /dev/null +++ b/test/fixtures/sensor_subscriptions/cayenne_lpp_1_6_1_metadata.inc @@ -0,0 +1,370 @@ +// Unmodified scalar metadata methods from Electronic Cats CayenneLPP1.6.1. +// https://github.com/ElectronicCats/CayenneLPP +// Source package pinned by platformio.ini to electroniccats/CayenneLPP@1.6.1. +// Upstream src/CayenneLPP.cpp SHA256: 3dab5bca79280efaa0f286741c08e8e1bd3cd6de01a870154b17131e5322f002 +// MIT license is preserved in CAYENNE_LICENSE.txt. +#define LPP_DIGITAL_INPUT_SIZE 1 +#define LPP_DIGITAL_OUTPUT_SIZE 1 +#define LPP_ANALOG_INPUT_SIZE 2 +#define LPP_ANALOG_OUTPUT_SIZE 2 +#define LPP_GENERIC_SENSOR_SIZE 4 +#define LPP_LUMINOSITY_SIZE 2 +#define LPP_PRESENCE_SIZE 1 +#define LPP_TEMPERATURE_SIZE 2 +#define LPP_RELATIVE_HUMIDITY_SIZE 1 +#define LPP_ACCELEROMETER_SIZE 6 +#define LPP_BAROMETRIC_PRESSURE_SIZE 2 +#define LPP_VOLTAGE_SIZE 2 +#define LPP_CURRENT_SIZE 2 +#define LPP_FREQUENCY_SIZE 4 +#define LPP_PERCENTAGE_SIZE 1 +#define LPP_ALTITUDE_SIZE 2 +#define LPP_POWER_SIZE 2 +#define LPP_DISTANCE_SIZE 4 +#define LPP_ENERGY_SIZE 4 +#define LPP_DIRECTION_SIZE 2 +#define LPP_UNIXTIME_SIZE 4 +#define LPP_GYROMETER_SIZE 6 +#define LPP_GPS_SIZE 9 +#define LPP_SWITCH_SIZE 1 +#define LPP_CONCENTRATION_SIZE 2 +#define LPP_COLOUR_SIZE 3 +#define LPP_MIN_POLYLINE_SIZE 8 +#define LPP_DIGITAL_INPUT_MULT 1 +#define LPP_DIGITAL_OUTPUT_MULT 1 +#define LPP_ANALOG_INPUT_MULT 100 +#define LPP_ANALOG_OUTPUT_MULT 100 +#define LPP_GENERIC_SENSOR_MULT 1 +#define LPP_LUMINOSITY_MULT 1 +#define LPP_PRESENCE_MULT 1 +#define LPP_TEMPERATURE_MULT 10 +#define LPP_RELATIVE_HUMIDITY_MULT 2 +#define LPP_ACCELEROMETER_MULT 1000 +#define LPP_BAROMETRIC_PRESSURE_MULT 10 +#define LPP_VOLTAGE_MULT 100 +#define LPP_CURRENT_MULT 1000 +#define LPP_FREQUENCY_MULT 1 +#define LPP_PERCENTAGE_MULT 1 +#define LPP_ALTITUDE_MULT 1 +#define LPP_POWER_MULT 1 +#define LPP_DISTANCE_MULT 1000 +#define LPP_ENERGY_MULT 1000 +#define LPP_DIRECTION_MULT 1 +#define LPP_UNIXTIME_MULT 1 +#define LPP_GYROMETER_MULT 100 +#define LPP_GPS_LAT_LON_MULT 10000 +#define LPP_GPS_ALT_MULT 100 +#define LPP_SWITCH_MULT 1 +#define LPP_CONCENTRATION_MULT 1 +#define LPP_COLOUR_MULT 1 +class CayenneLPP { +public: + static uint8_t getTypeSize(uint8_t); + static uint32_t getTypeMultiplier(uint8_t); + static bool getTypeSigned(uint8_t); +}; +uint8_t CayenneLPP::getTypeSize(uint8_t type) { + + switch (type) { + +#ifndef CAYENNE_DISABLE_DIGITAL_INPUT + case LPP_DIGITAL_INPUT: + return LPP_DIGITAL_INPUT_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_DIGITAL_OUTPUT + case LPP_DIGITAL_OUTPUT: + return LPP_DIGITAL_OUTPUT_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_ANALOG_INPUT + case LPP_ANALOG_INPUT: + return LPP_ANALOG_INPUT_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_ANALOG_OUTPUT + case LPP_ANALOG_OUTPUT: + return LPP_ANALOG_OUTPUT_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_GENERIC_SENSOR + case LPP_GENERIC_SENSOR: + return LPP_GENERIC_SENSOR_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_LUMINOSITY + case LPP_LUMINOSITY: + return LPP_LUMINOSITY_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_PRESENCE + case LPP_PRESENCE: + return LPP_PRESENCE_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_TEMPERATURE + case LPP_TEMPERATURE: + return LPP_TEMPERATURE_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_RELATIVE_HUMIDITY + case LPP_RELATIVE_HUMIDITY: + return LPP_RELATIVE_HUMIDITY_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_ACCELEROMETER + case LPP_ACCELEROMETER: + return LPP_ACCELEROMETER_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_BAROMETRIC_PRESSUE + case LPP_BAROMETRIC_PRESSURE: + return LPP_BAROMETRIC_PRESSURE_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_VOLTAGE + case LPP_VOLTAGE: + return LPP_VOLTAGE_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_CURRENT + case LPP_CURRENT: + return LPP_CURRENT_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_FREQUENCY + case LPP_FREQUENCY: + return LPP_FREQUENCY_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_PERCENTAGE + case LPP_PERCENTAGE: + return LPP_PERCENTAGE_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_ALTITUDE + case LPP_ALTITUDE: + return LPP_ALTITUDE_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_POWER + case LPP_POWER: + return LPP_POWER_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_DISTANCE + case LPP_DISTANCE: + return LPP_DISTANCE_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_ENERGY + case LPP_ENERGY: + return LPP_ENERGY_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_DIRECTION + case LPP_DIRECTION: + return LPP_DIRECTION_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_UNIX_TIME + case LPP_UNIXTIME: + return LPP_UNIXTIME_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_GYROMETER + case LPP_GYROMETER: + return LPP_GYROMETER_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_GPS + case LPP_GPS: + return LPP_GPS_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_SWITCH + case LPP_SWITCH: + return LPP_SWITCH_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_CONCENTRATION + case LPP_CONCENTRATION: + return LPP_CONCENTRATION_SIZE; +#endif + +#ifndef CAYENNE_DISABLE_COLOUR + case LPP_COLOUR: + return LPP_COLOUR_SIZE; +#endif + + default: + return 0; + } +} +uint32_t CayenneLPP::getTypeMultiplier(uint8_t type) { + + switch (type) { + +#ifndef CAYENNE_DISABLE_DIGITAL_INPUT + case LPP_DIGITAL_INPUT: + return LPP_DIGITAL_INPUT_MULT; +#endif + +#ifndef CAYENNE_DISABLE_DIGITAL_OUTPUT + case LPP_DIGITAL_OUTPUT: + return LPP_DIGITAL_OUTPUT_MULT; +#endif + +#ifndef CAYENNE_DISABLE_ANALOG_INPUT + case LPP_ANALOG_INPUT: + return LPP_ANALOG_INPUT_MULT; +#endif + +#ifndef CAYENNE_DISABLE_ANALOG_OUTPUT + case LPP_ANALOG_OUTPUT: + return LPP_ANALOG_OUTPUT_MULT; +#endif + +#ifndef CAYENNE_DISABLE_GENERIC_SENSOR + case LPP_GENERIC_SENSOR: + return LPP_GENERIC_SENSOR_MULT; +#endif + +#ifndef CAYENNE_DISABLE_LUMINOSITY + case LPP_LUMINOSITY: + return LPP_LUMINOSITY_MULT; +#endif + +#ifndef CAYENNE_DISABLE_PRESENCE + case LPP_PRESENCE: + return LPP_PRESENCE_MULT; +#endif + +#ifndef CAYENNE_DISABLE_TEMPERATURE + case LPP_TEMPERATURE: + return LPP_TEMPERATURE_MULT; +#endif + +#ifndef CAYENNE_DISABLE_RELATIVE_HUMIDITY + case LPP_RELATIVE_HUMIDITY: + return LPP_RELATIVE_HUMIDITY_MULT; +#endif + +#ifndef CAYENNE_DISABLE_ACCELEROMETER + case LPP_ACCELEROMETER: + return LPP_ACCELEROMETER_MULT; +#endif + +#ifndef CAYENNE_DISABLE_BAROMETRIC_PRESSUE + case LPP_BAROMETRIC_PRESSURE: + return LPP_BAROMETRIC_PRESSURE_MULT; +#endif + +#ifndef CAYENNE_DISABLE_VOLTAGE + case LPP_VOLTAGE: + return LPP_VOLTAGE_MULT; +#endif + +#ifndef CAYENNE_DISABLE_CURRENT + case LPP_CURRENT: + return LPP_CURRENT_MULT; +#endif + +#ifndef CAYENNE_DISABLE_FREQUENCY + case LPP_FREQUENCY: + return LPP_FREQUENCY_MULT; +#endif + +#ifndef CAYENNE_DISABLE_PERCENTAGE + case LPP_PERCENTAGE: + return LPP_PERCENTAGE_MULT; +#endif + +#ifndef CAYENNE_DISABLE_ALTITUDE + case LPP_ALTITUDE: + return LPP_ALTITUDE_MULT; +#endif + +#ifndef CAYENNE_DISABLE_POWER + case LPP_POWER: + return LPP_POWER_MULT; +#endif + +#ifndef CAYENNE_DISABLE_DISTANCE + case LPP_DISTANCE: + return LPP_DISTANCE_MULT; +#endif + +#ifndef CAYENNE_DISABLE_ENERGY + case LPP_ENERGY: + return LPP_ENERGY_MULT; +#endif + +#ifndef CAYENNE_DISABLE_DIRECTION + case LPP_DIRECTION: + return LPP_DIRECTION_MULT; +#endif + +#ifndef CAYENNE_DISABLE_UNIX_TIME + case LPP_UNIXTIME: + return LPP_UNIXTIME_MULT; +#endif + +#ifndef CAYENNE_DISABLE_GYROMETER + case LPP_GYROMETER: + return LPP_GYROMETER_MULT; +#endif + +#ifndef CAYENNE_DISABLE_SWITCH + case LPP_SWITCH: + return LPP_SWITCH_MULT; +#endif + +#ifndef CAYENNE_DISABLE_CONCENTRATION + case LPP_CONCENTRATION: + return LPP_CONCENTRATION_MULT; +#endif + +#ifndef CAYENNE_DISABLE_COLOUR + case LPP_COLOUR: + return LPP_COLOUR_MULT; +#endif + + default: + return 0; + } +} +bool CayenneLPP::getTypeSigned(uint8_t type) { + + switch (type) { +#ifndef CAYENNE_DISABLE_VOLTAGE + case LPP_VOLTAGE: +#endif +#ifndef CAYENNE_DISABLE_CURRENT + case LPP_CURRENT: +#endif +#ifndef CAYENNE_DISABLE_ANALOG_INPUT + case LPP_ANALOG_INPUT: +#endif +#ifndef CAYENNE_DISABLE_ANALOG_OUTPUT + case LPP_ANALOG_OUTPUT: +#endif +#ifndef CAYENNE_DISABLE_TEMPERATURE + case LPP_TEMPERATURE: +#endif +#ifndef CAYENNE_DISABLE_ACCELEROMETER + case LPP_ACCELEROMETER: +#endif +#ifndef CAYENNE_DISABLE_ALTITUDE + case LPP_ALTITUDE: +#endif +#ifndef CAYENNE_DISABLE_GYROMETER + case LPP_GYROMETER: +#endif +#ifndef CAYENNE_DISABLE_GPS + case LPP_GPS: +#endif + return true; + } + return false; +} diff --git a/test/fixtures/sensor_subscriptions/test_sensor_subscriptions.cpp b/test/fixtures/sensor_subscriptions/test_sensor_subscriptions.cpp new file mode 100644 index 000000000..a8cbd7d4c --- /dev/null +++ b/test/fixtures/sensor_subscriptions/test_sensor_subscriptions.cpp @@ -0,0 +1,323 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "LPPDataHelpers.h" +#include "cayenne_lpp_1_6_1_metadata.inc" + +#define PERM_ACL_ROLE_MASK 7 +#define PERM_ACL_READ_ONLY 1 +#define REQ_TYPE_SUBSCRIBE 8 + +struct ClientInfo { + uint8_t permissions = 3; + int id = 1; + uint8_t shared_secret[32] = {}, out_path[MAX_PATH_SIZE] = {}, out_path_len = 0; + struct { struct { + uint32_t push_tag = 0, expiry_timestamp = 0; + uint16_t scope_region_id = 0; + uint8_t min_deltas_len = 0, min_deltas[14] = {}, prev_telem[14] = {}; + } sensor; } extra; + bool isAdmin() const { return (permissions & 7) == 3; } +}; +struct TransportKey {}; +struct RegionEntry { + uint16_t id = 1; + char name[31] = "test"; + bool isWildcard() const { return false; } +}; +struct RegionMap { + RegionEntry region; + RegionEntry* getDefaultRegion() { return ®ion; } + RegionEntry* findById(uint16_t id) { return id == region.id ? ®ion : nullptr; } + int getTransportKeysFor(const RegionEntry&, TransportKey*, int) { return 1; } +}; +struct Telemetry { + std::vector bytes; + const size_t capacity; + explicit Telemetry(size_t max_size) : capacity(max_size) {} + bool add(uint8_t channel, uint8_t type) { + const size_t size = 2 + LPPData::getDataSize(type); + if (bytes.size() + size > capacity) return false; + bytes.push_back(channel); + bytes.push_back(type); + bytes.insert(bytes.end(), size - 2, 0); + return true; + } + uint8_t* getBuffer() { return bytes.data(); } + uint8_t getSize() { return bytes.size(); } +}; +struct Clock { + uint32_t getCurrentTime() { return 100; } + uint32_t getCurrentTimeUnique() { return 100; } +}; +struct ACL { + std::vector clients; + int getNumClients() { return clients.size(); } + ClientInfo* getClientByIdx(int index) { return &clients.at(index); } +}; +class SensorMesh { +public: + SensorMesh(); + uint8_t reply_data[MAX_PACKET_PAYLOAD] = {}; + Telemetry telemetry; + Clock clock; + RegionMap region_map; + RegionEntry* recv_pkt_region = nullptr; + ACL acl; + struct { uint8_t path_hash_mode = 0; } _prefs; + bool refuse_allocation = false, refuse_queue = false; + unsigned allocations = 0, attempts = 0, queued = 0; + mesh::Packet owned_packet; + Clock* getRTCClock() { return &clock; } + bool telemHasChanged(ClientInfo*); + void snapshotTelemetry(ClientInfo*); + uint8_t handleRequest(ClientInfo*, uint32_t, uint8_t, uint8_t*, size_t, + size_t = mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY); + void pushSubscriptions(); + // Replace only allocator/queue boundaries. The production change detector, + // subscription parser, and complete subscription loop body execute below. + mesh::Packet* createDatagram(uint8_t, int, const uint8_t*, const uint8_t*, size_t length) { + ++allocations; + if (length > mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY) return nullptr; + return refuse_allocation ? nullptr : &owned_packet; + } + bool sendDirect(mesh::Packet*, const uint8_t*, uint8_t, uint32_t) { + ++attempts; + if (refuse_queue) return false; + ++queued; + return true; + } + bool sendFloodScoped(const TransportKey&, mesh::Packet* p, uint32_t delay, uint8_t) { + return sendDirect(p, nullptr, 0, delay); + } +}; +#include "production.inc" + +static unsigned subscribe(SensorMesh& target, ClientInfo& client, uint8_t type, + unsigned delta_size, unsigned payload_size = 255, uint32_t tag = 123) { + uint8_t request[8 + 14] = {}; + const uint16_t timeout = 600; + memcpy(request, &tag, 4); + memcpy(request + 4, &timeout, 2); + request[7] = 2 + delta_size; + request[8] = 1; + request[9] = type; + const size_t length = payload_size == 255 ? 8 + 2 + delta_size : payload_size; + return target.handleRequest(&client, 51, REQ_TYPE_SUBSCRIBE, request, length); +} +static void input_checks() { + for (unsigned type = 0; type < 256; ++type) { + if (LPPData::isScalarType(type)) continue; + SensorMesh target; + ClientInfo client; + const ClientInfo original = client; + const unsigned size = LPPData::getDataSize(type); + assert(subscribe(target, client, type, size) == 8); + assert(client.extra.sensor.min_deltas_len == 0); + assert(!memcmp(&client.extra.sensor, &original.extra.sensor, sizeof(client.extra.sensor))); + // Defensive detector must also reject unsupported records already in RAM. + client.extra.sensor.min_deltas_len = 2 + size; + client.extra.sensor.min_deltas[0] = 1; + client.extra.sensor.min_deltas[1] = type; + assert(!target.telemHasChanged(&client)); + } + SensorMesh target; + ClientInfo client; + assert(subscribe(target, client, LPP_VOLTAGE, 2) == 12); + const ClientInfo original = client; + for (unsigned length = 0; length < 12; ++length) { + assert(subscribe(target, client, LPP_VOLTAGE, 2, length, 456) == (length < 8 ? 0 : 8)); + assert(!memcmp(&client.extra.sensor, &original.extra.sensor, sizeof(client.extra.sensor))); + } + assert(subscribe(target, client, LPP_GPS, 9, 255, 456) == 8); + assert(!memcmp(&client.extra.sensor, &original.extra.sensor, sizeof(client.extra.sensor))); +} +static void scalar_checks() { + unsigned supported = 0; + for (unsigned type = 0; type < 256; ++type) { + if (!LPPData::isScalarType(type)) continue; + ++supported; + const unsigned size = LPPData::getDataSize(type); + assert(size >= 1 && size <= 4); + assert(size == CayenneLPP::getTypeSize(type)); + assert(LPPData::getMultiplier(type) == CayenneLPP::getTypeMultiplier(type)); + assert(LPPData::isSigned(type) == CayenneLPP::getTypeSigned(type)); + SensorMesh target; + ClientInfo client; + assert(subscribe(target, client, type, size) == 12); + assert(client.extra.sensor.min_deltas_len == 2 + size); + } + assert(supported == 22); + assert(LPPData::getDataSize(LPP_RELATIVE_HUMIDITY) == 1); + assert(LPPData::getMultiplier(LPP_RELATIVE_HUMIDITY) == 2); + uint8_t humidity[] = {127}; + assert(LPPData::getFloat(humidity, 1, 2, false) == 63.5f); + uint8_t encoded[4] = {}; + assert(LPPData::putFloat(encoded, 63.5f, 1, 2, false) == 1); + assert(encoded[0] == 127); + for (uint8_t type : {LPP_VOLTAGE, LPP_CURRENT}) { + uint8_t buffer[4] = {}; + assert(LPPData::isSigned(type)); + const uint32_t multiplier = LPPData::getMultiplier(type); + assert(LPPData::putFloat(buffer, -1.25f, 2, multiplier, true) == 2); + assert(LPPData::getFloat(buffer, 2, multiplier, true) == -1.25f); + LPPReader reader(buffer, 2); + float value = 0; + assert(type == LPP_VOLTAGE ? reader.readVoltage(value) : reader.readCurrent(value)); + assert(value == -1.25f); + LPPWriter writer(buffer, sizeof(buffer)); + assert(type == LPP_VOLTAGE ? writer.writeVoltage(1, -1.25f) : writer.writeData(1, type, -1.25f)); + assert(writer.length() == 4); + assert(buffer[0] == 1 && buffer[1] == type); + assert(LPPData::getFloat(buffer + 2, 2, multiplier, true) == -1.25f); + } + SensorMesh target; + ClientInfo client; + assert(subscribe(target, client, LPP_RELATIVE_HUMIDITY, 1) == 12); + target.telemetry.bytes = {1, LPP_RELATIVE_HUMIDITY, 127, 2, LPP_VOLTAGE, 1, 104}; + assert(target.telemHasChanged(&client)); + target.snapshotTelemetry(&client); + assert(client.extra.sensor.prev_telem[2] == 127); + assert(!target.telemHasChanged(&client)); +} +static void admission_checks() { + for (uint8_t stored_path : {0, 1, 0xFE, 0xFF}) { + for (bool refuse_allocation : {false, true}) { + SensorMesh target; + target.acl.clients.resize(1); + ClientInfo& client = target.acl.clients[0]; + client.out_path_len = stored_path; + assert(subscribe(target, client, LPP_VOLTAGE, 2) == 12); + target.telemetry.bytes = {1, LPP_VOLTAGE, 1, 104}; + target.refuse_allocation = refuse_allocation; + target.refuse_queue = !refuse_allocation; + target.pushSubscriptions(); + assert(target.queued == 0); + assert(target.telemHasChanged(&client)); + assert(client.extra.sensor.prev_telem[2] == 0); + assert(client.extra.sensor.prev_telem[3] == 0); + target.refuse_allocation = target.refuse_queue = false; + target.pushSubscriptions(); + assert(target.queued == 1); + assert(client.extra.sensor.prev_telem[2] == 1); + assert(client.extra.sensor.prev_telem[3] == 104); + target.pushSubscriptions(); + assert(target.queued == 1); + } + } +} +static void writer_checks() { + uint8_t buffer[16]; + memset(buffer, 0xAA, sizeof(buffer)); + LPPWriter writer(buffer, sizeof(buffer)); + for (float value : {std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), + -std::numeric_limits::infinity()}) { + assert(!writer.writeData(1, LPP_VOLTAGE, value)); + assert(writer.length() == 0); + for (uint8_t byte : buffer) assert(byte == 0xAA); + } + for (uint8_t type : {LPP_GPS, LPP_ACCELEROMETER, LPP_GYROMETER, LPP_COLOUR, LPP_POLYLINE, 0xFF}) { + assert(!writer.writeData(1, type, 1.0f)); + assert(writer.length() == 0); + for (uint8_t byte : buffer) assert(byte == 0xAA); + } + assert(writer.writeData(1, LPP_RELATIVE_HUMIDITY, 63.5f)); + assert(writer.length() == 3); + assert(buffer[0] == 1 && buffer[1] == LPP_RELATIVE_HUMIDITY && buffer[2] == 127); + for (unsigned i = 3; i < sizeof(buffer); ++i) assert(buffer[i] == 0xAA); + uint8_t short_buffer[2] = {0xAA, 0xAA}; + LPPWriter short_writer(short_buffer, sizeof(short_buffer)); + assert(!short_writer.writeData(1, LPP_VOLTAGE, 3.6f)); + assert(short_writer.length() == 0 && short_buffer[0] == 0xAA && short_buffer[1] == 0xAA); +} +static void capacity_checks() { + SensorMesh target; + assert(target.telemetry.capacity + 8 <= mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY); + while (target.telemetry.add(1, LPP_RELATIVE_HUMIDITY)) {} + assert(size_t(target.telemetry.getSize()) + 8 <= mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY); + target.acl.clients.resize(1); + ClientInfo& client = target.acl.clients[0]; + assert(subscribe(target, client, LPP_VOLTAGE, 2) == 12); + // Defense if an oversized collection nevertheless reaches the push loop. + target.telemetry.bytes = {1, LPP_VOLTAGE, 1, 104}; + target.telemetry.bytes.resize(mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY - 7, 0); + target.pushSubscriptions(); + assert(target.allocations == 0); + assert(target.queued == 0); + assert(target.telemHasChanged(&client)); + target.telemetry.bytes.resize(4); + target.pushSubscriptions(); + assert(target.queued == 1); +} +static void conversion_checks() { + uint8_t encoded[12] = {}; + for (uint8_t size = 1; size <= 4; ++size) { + assert(LPPData::putFloat(encoded, -12.0f, size, 1, true) == size); + assert(LPPData::getFloat(encoded, size, 1, true) == -12.0f); + } + for (uint8_t size = 1; size <= 4; ++size) { + const uint32_t mask = size == 4 ? UINT32_MAX : (uint32_t(1) << (8 * size)) - 1; + const uint32_t sign_bit = uint32_t(1) << (8 * size - 1); + uint8_t expected[4] = {}; + uint32_t raw = sign_bit; + for (unsigned i = size; i > 0; --i) { expected[i - 1] = raw & 255; raw >>= 8; } + const float minimum = -(float)sign_bit; + assert(LPPData::putFloat(encoded, minimum, size, 1, true) == size); + assert(!memcmp(encoded, expected, size)); + assert(LPPData::getFloat(encoded, size, 1, true) == minimum); + raw = sign_bit - 1; + for (unsigned i = size; i > 0; --i) { expected[i - 1] = raw & 255; raw >>= 8; } + const float maximum = (float)(sign_bit - 1); + assert(LPPData::putFloat(encoded, maximum, size, 1, true) == size); + assert(!memcmp(encoded, expected, size)); + assert(LPPData::putFloat(encoded, maximum * 2, size, 1, true) == size); + assert(!memcmp(encoded, expected, size)); + assert(LPPData::putFloat(encoded, minimum * 2, size, 1, true) == size); + assert(encoded[0] == 128); + for (unsigned i = 1; i < size; ++i) assert(encoded[i] == 0); + raw = mask; + for (unsigned i = size; i > 0; --i) { expected[i - 1] = raw & 255; raw >>= 8; } + assert(LPPData::putFloat(encoded, (float)mask, size, 1, false) == size); + assert(!memcmp(encoded, expected, size)); + assert(LPPData::putFloat(encoded, (float)mask * 2, size, 1, false) == size); + assert(!memcmp(encoded, expected, size)); + assert(LPPData::putFloat(encoded, -1.0f, size, 1, false) == size); + for (unsigned i = 0; i < size; ++i) assert(encoded[i] == 0); + } + uint8_t all_ones[] = {255, 255, 255, 255}; + float rounded = LPPData::getFloat(all_ones, 4, 1, false); + assert(LPPData::putFloat(encoded, rounded, 4, 1, false) == 4); + assert(!memcmp(encoded, all_ones, 4)); + for (float value : {std::numeric_limits::quiet_NaN(), + std::numeric_limits::infinity(), + -std::numeric_limits::infinity()}) { + memset(encoded, 0xAA, sizeof(encoded)); + assert(LPPData::putFloat(encoded, value, 4, 1, false) == 0); + for (uint8_t byte : encoded) assert(byte == 0xAA); + } + for (uint8_t size : {0, 5, 6, 9}) { + memset(encoded, 0xAA, sizeof(encoded)); + assert(LPPData::putFloat(encoded, 12.0f, size, 1, true) == 0); + assert(encoded[0] == 0xAA); + assert(LPPData::getFloat(encoded, size, 1, true) == 0.0f); + } + assert(LPPData::putFloat(encoded, 12.0f, 1, 0, true) == 0); + assert(LPPData::getFloat(encoded, 1, 0, true) == 0.0f); +} +int main(int argc, char** argv) { + assert(argc == 2); + if (!strcmp(argv[1], "scalar")) scalar_checks(); + else if (!strcmp(argv[1], "input")) input_checks(); + else if (!strcmp(argv[1], "admission")) admission_checks(); + else if (!strcmp(argv[1], "conversion")) conversion_checks(); + else if (!strcmp(argv[1], "capacity")) capacity_checks(); + else if (!strcmp(argv[1], "writer")) writer_checks(); + else assert(false); + std::cout << "Sensor subscription checks passed\n"; +} diff --git a/test/test_sensor_reply_bounds.py b/test/test_sensor_reply_bounds.py new file mode 100644 index 000000000..7a274ed31 --- /dev/null +++ b/test/test_sensor_reply_bounds.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Run actual Sensor response handlers, history aggregation and packet builders.""" +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/sensor_reply_bounds/test_sensor_reply_bounds.cpp" + + +def production_methods(transform=None, room_transform=None): + source = (ROOT / "examples/simple_sensor/SensorMesh.cpp").read_text() + if transform: + source = transform(source) + handler = extract_braced(source, "uint8_t SensorMesh::handleRequest(") + begin = handler.index(" if (req_type == REQ_TYPE_GET_TELEMETRY_DATA") + generated = extract_braced(source, "static uint8_t getTelemetryPermissions(") + "\n" + if "static size_t boundedTelemPrefix(" in source: + generated += extract_braced(source, "static size_t boundedTelemPrefix(") + "\n" + generated += handler[:begin] + for signature in ("if (req_type == REQ_TYPE_GET_TELEMETRY_DATA", "if (req_type == REQ_TYPE_GET_AVG_MIN_MAX"): + generated += extract_braced(handler, signature) + "\n" + generated += "return 0;\n}\n" + room = (ROOT / "examples/simple_room_server/MyMesh.cpp").read_text() + if room_transform: + room = room_transform(room) + room_handler = extract_braced(room, "int MyMesh::handleRequest(") + room_begin = room_handler.index(" if (payload[0] == REQ_TYPE_GET_STATUS)") + generated += "#undef REQ_TYPE_GET_TELEMETRY_DATA\n#define REQ_TYPE_GET_TELEMETRY_DATA ROOM_REQ_TYPE_GET_TELEMETRY_DATA\n" + generated += room_handler[:room_begin] + generated += extract_braced(room_handler, "if (payload[0] == REQ_TYPE_GET_TELEMETRY_DATA)") + "\nreturn 0;\n}\n" + generated += "#undef REQ_TYPE_GET_TELEMETRY_DATA\n#define REQ_TYPE_GET_TELEMETRY_DATA SENSOR_REQ_TYPE_GET_TELEMETRY_DATA\n" + history = (ROOT / "examples/simple_sensor/TimeSeriesData.cpp").read_text() + generated += extract_braced(history, "void TimeSeriesData::calcMinMaxAvg(") + "\n" + mesh = (ROOT / "src/Mesh.cpp").read_text() + generated += "namespace mesh {\n#define MAX_COMBINED_PATH (MAX_PACKET_PAYLOAD - 2 - CIPHER_BLOCK_SIZE)\n" + for signature in ("Packet* Mesh::createPathReturn(const Identity& dest,", + "Packet* Mesh::createPathReturn(const uint8_t* dest_hash,", "Packet* Mesh::createDatagram("): + generated += extract_braced(mesh, signature) + "\n" + packet = (ROOT / "src/Packet.cpp").read_text() + for signature in ("Packet::Packet()", "bool Packet::isValidPathLen("): + generated += extract_braced(packet, signature) + "\n" + return generated + "}\n" + + +def production_capacities(sensor_transform=None, room_transform=None): + definitions = [] + for role, path, transform in ( + ("SENSOR", "examples/simple_sensor/SensorMesh.cpp", sensor_transform), + ("ROOM", "examples/simple_room_server/MyMesh.cpp", room_transform), + ): + source = (ROOT / path).read_text() + if transform: + source = transform(source) + matches = re.findall(r"\btelemetry\(([^)\n]+)\)", source) + if len(matches) != 1: + raise AssertionError("one actual telemetry constructor required: " + path) + definitions.append("#define " + role + "_TELEMETRY_CAPACITY (" + matches[0] + ")") + opcode = re.search(r"#define REQ_TYPE_GET_TELEMETRY_DATA\s+(0x[0-9a-fA-F]+)", source).group(1) + definitions.append("#define " + role + "_REQ_TYPE_GET_TELEMETRY_DATA " + opcode) + return "\n".join(definitions) + "\n" + + +class SensorReplyBoundsTest(unittest.TestCase): + def execute(self, case, transform=None, room_transform=None): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "host C++17 compiler required") + with tempfile.TemporaryDirectory(prefix="sensor-reply-bounds-") as directory: + work = Path(directory) + (work / "telemetry_capacity.inc").write_text(production_capacities(transform, room_transform), encoding="ascii") + (work / "production.inc").write_text(production_methods(transform, room_transform), encoding="ascii") + history_types = (ROOT / "examples/simple_sensor/TimeSeriesData.h").read_text() + history_types = "\n".join(line for line in history_types.splitlines() if not line.startswith("#")) + (work / "history_types.inc").write_text(history_types, encoding="ascii") + binary = work / "sensor.exe" + command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", "-Wno-reorder", + "-I" + str(work), "-I" + str(ROOT / "src"), str(FIXTURE), "-o", str(binary)] + if sys.platform.startswith("linux"): + command[1:1] = ["-fsanitize=address,undefined,float-cast-overflow", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] + built = subprocess.run(command, capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + return subprocess.run([str(binary), case], capture_output=True, text=True, timeout=30) + + def check_case(self, case): + run = self.execute(case) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + self.assertIn("Sensor response bounds and packet admission passed", run.stdout) + + def test_room_telemetry_entries_fit_all_routes_and_preserve_composites(self): + self.check_case("room") + + def test_old_room_unbounded_copy_is_rejected(self): + def old(source): + begin = source.index(" const uint8_t* tbuf = telemetry.getBuffer();") + end = source.index(" return 4 + tlen; // reply_len", begin) + len(" return 4 + tlen; // reply_len") + return source[:begin] + """ uint8_t tlen = telemetry.getSize(); + memcpy(&reply_data[4], telemetry.getBuffer(), tlen); + return 4 + tlen; // reply_len""" + source[end:] + self.assertNotEqual(self.execute("room", room_transform=old).returncode, 0) + + def test_telemetry_entries_fit_every_valid_direct_and_flood_route(self): + self.check_case("telemetry") + + def test_history_entries_types_counts_and_empty_query_are_safe(self): + self.check_case("history") + + def test_packet_builder_accepts_maximum_telemetry_collection(self): + self.check_case("packet") + + def test_empty_history_query_has_no_synthetic_zero_values(self): + self.check_case("empty") + + def test_old_telemetry_copy_exceeds_route_admission(self): + def old(source): + begin = source.index(" const uint8_t* tbuf = telemetry.getBuffer();") + end = source.index(" return 4 + tlen; // reply_len", begin) + len(" return 4 + tlen; // reply_len") + return source[:begin] + """ uint8_t tlen = telemetry.getSize(); + memcpy(&reply_data[4], telemetry.getBuffer(), tlen); + return 4 + tlen; // reply_len""" + source[end:] + self.assertNotEqual(self.execute("telemetry", old).returncode, 0) + + def test_old_history_route_bounds_are_rejected(self): + def old(source): + return source.replace(" if (item_len > reply_capacity - ofs) break;", " (void)item_len;", 1) + self.assertNotEqual(self.execute("history", old).returncode, 0) + + def test_old_history_composite_types_are_rejected(self): + def old(source): + return source.replace("!LPPData::isScalarType(d->_lpp_type)\n || ", "", 1) + self.assertNotEqual(self.execute("history", old).returncode, 0) + + def test_old_history_provider_count_is_rejected(self): + def old(source): + return source.replace(" if (n < 0 || n > int(sizeof(data) / sizeof(data[0]))) return 0;", "", 1) + self.assertNotEqual(self.execute("history", old).returncode, 0) + + def test_old_empty_history_nonfinite_conversion_is_rejected(self): + def old(source): + return source.replace("\n || !isfinite(d->_min) || !isfinite(d->_max) || !isfinite(d->_avg)", "", 1) + self.assertNotEqual(self.execute("history", old).returncode, 0) + + def test_old_history_short_response_header_is_rejected(self): + def old(source): + return source.replace(" if (reply_capacity < 8) return 0; // tag and current timestamp", "", 1) + self.assertNotEqual(self.execute("history", old).returncode, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_sensor_subscriptions.py b/test/test_sensor_subscriptions.py new file mode 100644 index 000000000..5b19af733 --- /dev/null +++ b/test/test_sensor_subscriptions.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Execute Sensor subscription parsing and push admission with real LPP helpers.""" +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / 'examples/simple_sensor/SensorMesh.cpp' +HELPER = ROOT / 'src/helpers/sensors/LPPDataHelpers.h' +FIXTURE = ROOT / 'test/fixtures/sensor_subscriptions/test_sensor_subscriptions.cpp' + + +def production_methods(source): + methods = extract_braced(source, 'static float findTelemValue(') + '\n' + methods += extract_braced(source, 'bool SensorMesh::telemHasChanged(') + '\n' + if 'void SensorMesh::snapshotTelemetry(' in source: + methods += extract_braced(source, 'void SensorMesh::snapshotTelemetry(') + '\n' + handler = extract_braced(source, 'uint8_t SensorMesh::handleRequest(') + prefix = handler[:handler.index(' if (req_type == REQ_TYPE_GET_TELEMETRY_DATA')] + methods += prefix + extract_braced(handler, 'if (req_type == REQ_TYPE_SUBSCRIBE') + methods += '\nreturn 0;\n}\n' + loop = source[source.index(' uint32_t curr = getRTCClock()->getCurrentTime();', source.index('void SensorMesh::loop()')):] + constructor = extract_braced(source, 'SensorMesh::SensorMesh(') + capacity = re.search(r'telemetry\(([^)]+)\)', constructor).group(1) + methods += 'SensorMesh::SensorMesh() : telemetry(' + capacity + ') {}\n' + methods += 'void SensorMesh::pushSubscriptions() { uint32_t curr = 100;\n' + methods += extract_braced(loop, 'for (int i = 0; i < acl.getNumClients(); i++)') + '\n}\n' + packet = (ROOT / 'src/Packet.cpp').read_text() + methods += 'namespace mesh {\n' + extract_braced(packet, 'Packet::Packet()') + '\n' + methods += extract_braced(packet, 'bool Packet::isValidPathLen(') + '\n}\n' + return methods + + +class SensorSubscriptionTest(unittest.TestCase): + def execute(self, case, transform=None, helper_transform=None): + compiler = shutil.which('g++') or shutil.which('clang++') + self.assertIsNotNone(compiler, 'host C++17 compiler required') + source = SOURCE.read_text() + helper = HELPER.read_text() + if transform: + source = transform(source) + if helper_transform: + helper = helper_transform(helper) + with tempfile.TemporaryDirectory(prefix='sensor-subscription-') as directory: + work = Path(directory) + (work / 'production.inc').write_text(production_methods(source), encoding='ascii') + (work / 'LPPDataHelpers.h').write_text(helper, encoding='ascii') + binary = work / 'test' + args = [compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', '-Wno-unused-parameter', + '-I' + str(work), '-I' + str(ROOT / 'src'), str(FIXTURE), '-o', str(binary)] + if sys.platform.startswith('linux'): + args[1:1] = ['-fsanitize=address,undefined,float-cast-overflow', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie'] + built = subprocess.run(args, capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + return subprocess.run([str(binary), case], capture_output=True, text=True, timeout=30) + + def passed(self, case): + run = self.execute(case) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + self.assertIn('Sensor subscription checks passed', run.stdout) + + def test_supported_scalars_and_humidity_wire_encoding(self): + self.passed('scalar') + + def test_unsupported_types_truncation_and_existing_subscription_preserved(self): + self.passed('input') + + def test_allocation_and_queue_failures_keep_delta_available_for_retry(self): + self.passed('admission') + + def test_subscription_collection_and_push_fit_host_packet_budget(self): + self.passed('capacity') + + def test_scalar_writer_rejections_leave_buffer_and_length_untouched(self): + self.passed('writer') + + def test_scalar_width_sign_nonfinite_and_uint32_round_trip(self): + self.passed('conversion') + + def test_old_acceptance_of_vector_delta_records_is_rejected(self): + def old(source): + guard = 'LPPData::isScalarType(type)\n && item_len <= min_deltas_len - i' + self.assertIn(guard, source) + return source.replace(guard, 'item_len <= min_deltas_len - i', 1) + self.assertNotEqual(self.execute('input', old).returncode, 0) + + def test_old_invalid_request_overwrites_existing_push_tag(self): + def old(source): + marker = ' if (r && valid_deltas) {\n memcpy(&from->extra.sensor.push_tag, &payload[0], 4);' + self.assertIn(marker, source) + source = source.replace(marker, ' if (r && valid_deltas) {', 1) + marker = ' uint16_t timeout_secs;' + return source.replace(marker, ' memcpy(&from->extra.sensor.push_tag, &payload[0], 4);\n' + marker, 1) + self.assertNotEqual(self.execute('input', old).returncode, 0) + + def test_old_early_threshold_snapshot_is_rejected(self): + def old(source): + begin = source.index('bool SensorMesh::telemHasChanged(') + end = source.index('void SensorMesh::snapshotTelemetry(', begin) + before = source[begin:end] + self.assertIn(' return changed;', before) + altered = before.replace(' return changed;', ' if (changed) snapshotTelemetry(c);\n return changed;', 1) + return source[:begin] + altered + source[end:] + self.assertNotEqual(self.execute('admission', old).returncode, 0) + + def test_old_humidity_width_and_multiplier_are_rejected(self): + def width(helper): + marker = ' case LPP_ALTITUDE:\n case LPP_VOLTAGE:' + self.assertIn(marker, helper) + return helper.replace(marker, ' case LPP_RELATIVE_HUMIDITY:\n' + marker, 1) + def multiplier(helper): + marker = ' case LPP_RELATIVE_HUMIDITY:\n return 2;' + self.assertIn(marker, helper) + return helper.replace(marker, ' case LPP_RELATIVE_HUMIDITY:\n return 10;', 1) + for old in (width, multiplier): + with self.subTest(old=old.__name__): + self.assertNotEqual(self.execute('scalar', helper_transform=old).returncode, 0) + + def test_old_unbounded_subscription_collection_is_rejected(self): + def old(source): + marker = ' telemetry(mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY - 8)' + self.assertIn(marker, source) + return source.replace(marker, ' telemetry(MAX_PACKET_PAYLOAD - 4)', 1) + self.assertNotEqual(self.execute('capacity', old).returncode, 0) + + def test_old_push_allows_oversized_response_attempt(self): + def old(source): + marker = ' if (telemetry_len > mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY - 8) continue;' + self.assertIn(marker, source) + return source.replace(marker, ' if (telemetry_len > sizeof(reply_data) - 8) continue;', 1) + self.assertNotEqual(self.execute('capacity', old).returncode, 0) + + def test_old_signed_four_byte_mask_is_rejected_by_sanitizer(self): + def old(helper): + marker = 'size == sizeof(uint32_t)\n ? UINT32_MAX : (uint32_t(1) << (size * 8)) - 1' + self.assertIn(marker, helper) + return helper.replace(marker, '(1 << (size * 8)) - 1', 1) + self.assertNotEqual(self.execute('conversion', helper_transform=old).returncode, 0) + + def test_old_writer_commits_incomplete_header_on_encoding_failure(self): + def old(helper): + begin = helper.index(' if (LPPData::isScalarType(type)', helper.index('bool writeData(')) + end = helper.index(' return false;', begin) + original = ''' if (_len + 2 + sz <= _max_len) { + _buf[_len++] = channel; + _buf[_len++] = type; + _len += LPPData::putFloat(&_buf[_len], v, sz, mul, s); + return true; + } +''' + return helper[:begin] + original + helper[end:] + self.assertNotEqual(self.execute('writer', helper_transform=old).returncode, 0) + + def test_old_unguarded_float_cast_is_rejected_by_sanitizer(self): + def old(helper): + begin = helper.index(' if (size == 0', helper.index('static uint8_t putFloat(')) + end = helper.index(' // add bytes (MSB first)', begin) + original = ''' bool sign = value < 0; + if (sign) value = -value; + uint32_t v = value * multiplier; + if (is_signed && sign) { + const uint32_t mask = size == 4 ? UINT32_MAX : (uint32_t(1) << (size * 8)) - 1; + v = (uint32_t(0) - v) & mask; + } +''' + return helper[:begin] + original + helper[end:] + self.assertNotEqual(self.execute('conversion', helper_transform=old).returncode, 0) + + def test_header_and_ci_wiring(self): + self.assertIn(' electroniccats/CayenneLPP @ 1.6.1', (ROOT / 'platformio.ini').read_text()) + self.assertIn(' void snapshotTelemetry(ClientInfo* c);', + (ROOT / 'examples/simple_sensor/SensorMesh.h').read_text()) + self.assertIn(' python3 -B test/test_sensor_subscriptions.py -v\n', + (ROOT / '.github/workflows/run-unit-tests.yml').read_text()) + + +if __name__ == '__main__': + unittest.main()