diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index b7b6e04a9..afe5fd53c 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -131,6 +131,10 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include "Sensor/IndicatorSensor.h" #endif +#if defined(HAS_DS18B20) +#include "Sensor/DS18B20GpioSensor.h" +#endif + #if __has_include() #include "Sensor/TSL2561Sensor.h" #endif @@ -150,6 +154,12 @@ static constexpr uint16_t TX_HISTORY_KEY_ENVIRONMENT_TELEMETRY = 0x8002; void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) { +#if defined(HAS_DS18B20) + // Register the direct GPIO sensor before persisted config finishes loading. + // Environment telemetry still gates publication on its setting. + addSensor(i2cScanner, ScanI2C::DeviceType::NONE); +#endif + if (!moduleConfig.telemetry.environment_measurement_enabled && !ENVIRONMENTAL_TELEMETRY_MODULE_ENABLE) { return; } @@ -329,15 +339,14 @@ int32_t EnvironmentTelemetryModule::runOnce() default_telemetry_broadcast_interval_secs, numOnlineNodes))) && airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) && airTime->isTxAllowedAirUtil()) { - sendTelemetry(); - if (transmitHistory) + if (sendTelemetry() && transmitHistory) transmitHistory->setLastSentToMesh(TX_HISTORY_KEY_ENVIRONMENT_TELEMETRY); } else if (((lastSentToPhone == 0) || !Throttle::isWithinTimespanMs(lastSentToPhone, sendToPhoneIntervalMs)) && (service->isToPhoneQueueEmpty())) { // Just send to phone when it's not our time to send to mesh yet // Only send while queue is empty (phone assumed connected) - sendTelemetry(NODENUM_BROADCAST, true); - lastSentToPhone = millis(); + if (sendTelemetry(NODENUM_BROADCAST, true)) + lastSentToPhone = millis(); } } return min(sendToPhoneIntervalMs, result); diff --git a/src/modules/Telemetry/Sensor/DS18B20GpioSensor.cpp b/src/modules/Telemetry/Sensor/DS18B20GpioSensor.cpp new file mode 100644 index 000000000..24973b039 --- /dev/null +++ b/src/modules/Telemetry/Sensor/DS18B20GpioSensor.cpp @@ -0,0 +1,229 @@ +#include "configuration.h" + +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && defined(HAS_DS18B20) + +#include "DS18B20GpioSensor.h" +#include "mesh/Throttle.h" +#include + +DS18B20GpioSensor::DS18B20GpioSensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SENSOR_UNSET, "DS18B20") {} + +bool DS18B20GpioSensor::initDevice(TwoWire *, ScanI2C::FoundDevice *) +{ + status = 1; + + if (discover() && configureResolution()) { + LOG_INFO("%s: found one sensor on GPIO %d at %u-bit resolution", sensorName, DS18B20_PIN, RESOLUTION_BITS); + samplingState = SamplingState::IDLE; + } else { + samplingState = SamplingState::DISCOVER; + scheduleDelay(REDISCOVERY_DELAY_MS); + LOG_WARN("%s: no usable sensor on GPIO %d; will retry", sensorName, DS18B20_PIN); + } + + return true; +} + +bool DS18B20GpioSensor::discover() +{ + uint8_t candidate[8]{}; + uint8_t selected[8]{}; + bool found = false; + oneWire.reset_search(); + + while (oneWire.search(candidate)) { + if (OneWire::crc8(candidate, 7) != candidate[7]) { + LOG_WARN("%s: ignoring ROM with invalid CRC", sensorName); + continue; + } + + if (candidate[0] != ds18b20::FAMILY_CODE) { + LOG_DEBUG("%s: ignoring unsupported 1-Wire family 0x%02x", sensorName, candidate[0]); + continue; + } + + if (found) { + LOG_ERROR("%s: multiple DS18B20 sensors found; this target supports exactly one", sensorName); + return false; + } + + memcpy(selected, candidate, sizeof(selected)); + found = true; + } + + if (found) + memcpy(rom, selected, sizeof(rom)); + return found; +} + +bool DS18B20GpioSensor::readScratchpad(uint8_t scratchpad[ds18b20::SCRATCHPAD_SIZE]) +{ + if (!oneWire.reset()) { + LOG_WARN("%s: no presence pulse", sensorName); + return false; + } + + oneWire.select(rom); + oneWire.write(ds18b20::CMD_READ_SCRATCHPAD); + oneWire.read_bytes(scratchpad, ds18b20::SCRATCHPAD_SIZE); + if (OneWire::crc8(scratchpad, ds18b20::SCRATCHPAD_SIZE - 1) != scratchpad[ds18b20::SCRATCHPAD_SIZE - 1]) { + LOG_WARN("%s: scratchpad CRC check failed", sensorName); + return false; + } + + return true; +} + +bool DS18B20GpioSensor::configureResolution() +{ + uint8_t scratchpad[ds18b20::SCRATCHPAD_SIZE]{}; + if (!readScratchpad(scratchpad)) + return false; + + if (ds18b20::hasResolution(scratchpad, RESOLUTION_BITS)) + return true; + + if (!oneWire.reset()) { + LOG_WARN("%s: no presence pulse while setting resolution", sensorName); + return false; + } + + oneWire.select(rom); + oneWire.write(ds18b20::CMD_WRITE_SCRATCHPAD); + // Preserve alarm thresholds and avoid an unnecessary sensor EEPROM write. + oneWire.write(scratchpad[ds18b20::SCRATCHPAD_TH_INDEX]); + oneWire.write(scratchpad[ds18b20::SCRATCHPAD_TL_INDEX]); + oneWire.write(ds18b20::configForResolution(RESOLUTION_BITS)); + + if (!readScratchpad(scratchpad) || !ds18b20::hasResolution(scratchpad, RESOLUTION_BITS)) { + LOG_WARN("%s: unable to verify %u-bit resolution", sensorName, RESOLUTION_BITS); + return false; + } + + LOG_INFO("%s: configured %u-bit resolution", sensorName, RESOLUTION_BITS); + return true; +} + +bool DS18B20GpioSensor::startConversion() +{ + if (!oneWire.reset()) { + LOG_WARN("%s: no presence pulse before conversion", sensorName); + return false; + } + + oneWire.select(rom); + oneWire.write(ds18b20::CMD_CONVERT_T, 0); // externally powered; parasite-power hold is not used + return true; +} + +bool DS18B20GpioSensor::finishConversion(float *temperature) +{ + uint8_t scratchpad[ds18b20::SCRATCHPAD_SIZE]{}; + if (!readScratchpad(scratchpad)) + return false; + + const float result = ds18b20::decodeTemperature(scratchpad); + if (!ds18b20::isSaneTemperature(result)) { + LOG_WARN("%s: out-of-range temperature %.2f C", sensorName, result); + return false; + } + + *temperature = result; + return true; +} + +void DS18B20GpioSensor::scheduleDelay(uint32_t delayMs) +{ + delayStartedMs = millis(); + scheduledDelayMs = delayMs; + hasScheduledDelay = true; +} + +bool DS18B20GpioSensor::scheduledDelayActive() const +{ + return Throttle::isWithinTimespanMs(delayStartedMs, scheduledDelayMs); +} + +bool DS18B20GpioSensor::hasFreshMeasurement() const +{ + return hasCachedTemperature && Throttle::isWithinTimespanMs(lastMeasurementMs, SAMPLE_FRESH_MS); +} + +void DS18B20GpioSensor::recordFailure() +{ + ++consecutiveFailures; + conversionStartedMs = 0; + + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + samplingState = SamplingState::DISCOVER; + scheduleDelay(REDISCOVERY_DELAY_MS); + LOG_WARN("%s: %u consecutive failures; rediscovering in %us", sensorName, consecutiveFailures, + REDISCOVERY_DELAY_MS / 1000); + } else { + samplingState = SamplingState::IDLE; + scheduleDelay(RETRY_DELAY_MS); + } +} + +int32_t DS18B20GpioSensor::runOnce() +{ + if (hasScheduledDelay) { + if (scheduledDelayActive()) + return static_cast(scheduledDelayMs); + hasScheduledDelay = false; + } + + if (samplingState == SamplingState::DISCOVER) { + if (!discover() || !configureResolution()) { + scheduleDelay(REDISCOVERY_DELAY_MS); + LOG_WARN("%s: no usable sensor on GPIO %d; next discovery in %us", sensorName, DS18B20_PIN, + REDISCOVERY_DELAY_MS / 1000); + return REDISCOVERY_DELAY_MS; + } + + consecutiveFailures = 0; + samplingState = SamplingState::IDLE; + LOG_INFO("%s: recovered sensor on GPIO %d", sensorName, DS18B20_PIN); + } + + if (samplingState == SamplingState::IDLE) { + if (!startConversion()) { + recordFailure(); + return static_cast(scheduledDelayMs); + } + + samplingState = SamplingState::CONVERTING; + conversionStartedMs = millis(); + return CONVERSION_TIME_MS; + } + + if (Throttle::isWithinTimespanMs(conversionStartedMs, CONVERSION_TIME_MS)) + return CONVERSION_TIME_MS; + + float temperature = 0.0f; + if (!finishConversion(&temperature)) { + recordFailure(); + return static_cast(scheduledDelayMs); + } + + cachedTemperature = temperature; + lastMeasurementMs = millis(); + hasCachedTemperature = true; + consecutiveFailures = 0; + conversionStartedMs = 0; + samplingState = SamplingState::IDLE; + scheduleDelay(SAMPLE_INTERVAL_MS); + return SAMPLE_INTERVAL_MS; +} + +bool DS18B20GpioSensor::getMetrics(meshtastic_Telemetry *measurement) +{ + if (!hasFreshMeasurement()) + return false; + + measurement->variant.environment_metrics.has_temperature = true; + measurement->variant.environment_metrics.temperature = cachedTemperature; + return true; +} + +#endif diff --git a/src/modules/Telemetry/Sensor/DS18B20GpioSensor.h b/src/modules/Telemetry/Sensor/DS18B20GpioSensor.h new file mode 100644 index 000000000..1122bc26f --- /dev/null +++ b/src/modules/Telemetry/Sensor/DS18B20GpioSensor.h @@ -0,0 +1,71 @@ +#include "configuration.h" + +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && defined(HAS_DS18B20) + +#pragma once + +#ifndef DS18B20_PIN +#error "HAS_DS18B20 requires DS18B20_PIN" +#endif +#ifndef DS18B20_RESOLUTION_BITS +#error "HAS_DS18B20 requires DS18B20_RESOLUTION_BITS (9 through 12)" +#endif +#ifndef DS18B20_SAMPLE_INTERVAL_MS +#error "HAS_DS18B20 requires DS18B20_SAMPLE_INTERVAL_MS" +#endif +#if DS18B20_RESOLUTION_BITS < 9 || DS18B20_RESOLUTION_BITS > 12 +#error "DS18B20_RESOLUTION_BITS must be between 9 and 12" +#endif + +#include "DS18B20Protocol.h" +#include "TelemetrySensor.h" +#include + +class DS18B20GpioSensor : public TelemetrySensor +{ + public: + DS18B20GpioSensor(); + int32_t runOnce() override; + bool getMetrics(meshtastic_Telemetry *measurement) override; + bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + + private: + // Round up the DS18B20's 93.75/187.5/375/750 ms conversion times so a + // reading is never taken before the configured resolution is complete. + static constexpr uint8_t RESOLUTION_BITS = DS18B20_RESOLUTION_BITS; + static constexpr uint32_t CONVERSION_TIME_MS = RESOLUTION_BITS == 9 ? 94 + : RESOLUTION_BITS == 10 ? 188 + : RESOLUTION_BITS == 11 ? 375 + : 750; + static constexpr uint32_t SAMPLE_INTERVAL_MS = DS18B20_SAMPLE_INTERVAL_MS; + static constexpr uint32_t SAMPLE_FRESH_MS = (2 * SAMPLE_INTERVAL_MS) + CONVERSION_TIME_MS; + static constexpr uint32_t RETRY_DELAY_MS = 5UL * 1000; + static constexpr uint32_t REDISCOVERY_DELAY_MS = 60UL * 1000; + static constexpr uint8_t MAX_CONSECUTIVE_FAILURES = 3; + + enum class SamplingState : uint8_t { DISCOVER, IDLE, CONVERTING }; + + OneWire oneWire{DS18B20_PIN}; + uint8_t rom[8]{}; + float cachedTemperature = 0.0f; + uint32_t lastMeasurementMs = 0; + uint32_t conversionStartedMs = 0; + uint32_t delayStartedMs = 0; + uint32_t scheduledDelayMs = 0; + uint8_t consecutiveFailures = 0; + bool hasCachedTemperature = false; + bool hasScheduledDelay = false; + SamplingState samplingState = SamplingState::DISCOVER; + + bool discover(); + bool configureResolution(); + bool startConversion(); + bool readScratchpad(uint8_t scratchpad[ds18b20::SCRATCHPAD_SIZE]); + bool finishConversion(float *temperature); + void recordFailure(); + bool hasFreshMeasurement() const; + void scheduleDelay(uint32_t delayMs); + bool scheduledDelayActive() const; +}; + +#endif diff --git a/src/modules/Telemetry/Sensor/DS18B20Protocol.h b/src/modules/Telemetry/Sensor/DS18B20Protocol.h new file mode 100644 index 000000000..bf5172998 --- /dev/null +++ b/src/modules/Telemetry/Sensor/DS18B20Protocol.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +namespace ds18b20 +{ +constexpr uint8_t FAMILY_CODE = 0x28; +constexpr uint8_t CMD_CONVERT_T = 0x44; +constexpr uint8_t CMD_READ_SCRATCHPAD = 0xBE; +constexpr uint8_t CMD_WRITE_SCRATCHPAD = 0x4E; +constexpr uint8_t SCRATCHPAD_SIZE = 9; +constexpr uint8_t SCRATCHPAD_TH_INDEX = 2; +constexpr uint8_t SCRATCHPAD_TL_INDEX = 3; +constexpr uint8_t SCRATCHPAD_CONFIG_INDEX = 4; +constexpr uint8_t RESOLUTION_MASK = 0x60; +constexpr uint8_t CONFIG_9_BIT = 0x1F; +constexpr float MIN_TEMPERATURE_C = -55.0f; +constexpr float MAX_TEMPERATURE_C = 125.0f; + +constexpr uint8_t configForResolution(uint8_t resolutionBits) +{ + return static_cast(CONFIG_9_BIT | ((resolutionBits - 9) << 5)); +} + +inline float decodeTemperature(const uint8_t scratchpad[9]) +{ + const uint16_t raw = static_cast(scratchpad[0]) | (static_cast(scratchpad[1]) << 8); + return static_cast(static_cast(raw)) / 16.0f; +} + +inline bool isSaneTemperature(float temperature) +{ + return temperature >= MIN_TEMPERATURE_C && temperature <= MAX_TEMPERATURE_C; +} + +inline bool hasResolution(const uint8_t scratchpad[SCRATCHPAD_SIZE], uint8_t resolutionBits) +{ + return (scratchpad[SCRATCHPAD_CONFIG_INDEX] & RESOLUTION_MASK) == (configForResolution(resolutionBits) & RESOLUTION_MASK); +} +} // namespace ds18b20 diff --git a/test/test_ds18b20/test_main.cpp b/test/test_ds18b20/test_main.cpp new file mode 100644 index 000000000..2cc3c6a88 --- /dev/null +++ b/test/test_ds18b20/test_main.cpp @@ -0,0 +1,52 @@ +#include "TestUtil.h" +#include "modules/Telemetry/Sensor/DS18B20Protocol.h" +#include +#include + +void setUp(void) {} +void tearDown(void) {} + +void test_decodes_positive_12_bit_temperature() +{ + const uint8_t scratchpad[9] = {0x91, 0x01}; // 25.0625 C + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 25.0625f, ds18b20::decodeTemperature(scratchpad)); +} + +void test_decodes_negative_12_bit_temperature() +{ + const uint8_t scratchpad[9] = {0x5E, 0xFF}; // -10.125 C + TEST_ASSERT_FLOAT_WITHIN(0.0001f, -10.125f, ds18b20::decodeTemperature(scratchpad)); +} + +void test_accepts_ds18b20_operating_range() +{ + TEST_ASSERT_TRUE(ds18b20::isSaneTemperature(-55.0f)); + TEST_ASSERT_TRUE(ds18b20::isSaneTemperature(125.0f)); + TEST_ASSERT_FALSE(ds18b20::isSaneTemperature(-55.1f)); + TEST_ASSERT_FALSE(ds18b20::isSaneTemperature(125.1f)); +} + +void test_recognizes_resolution_configuration() +{ + uint8_t scratchpad[ds18b20::SCRATCHPAD_SIZE]{}; + for (uint8_t resolution = 9; resolution <= 12; ++resolution) { + scratchpad[ds18b20::SCRATCHPAD_CONFIG_INDEX] = ds18b20::configForResolution(resolution); + TEST_ASSERT_TRUE(ds18b20::hasResolution(scratchpad, resolution)); + } + + scratchpad[ds18b20::SCRATCHPAD_CONFIG_INDEX] = ds18b20::configForResolution(9); + TEST_ASSERT_FALSE(ds18b20::hasResolution(scratchpad, 12)); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_decodes_positive_12_bit_temperature); + RUN_TEST(test_decodes_negative_12_bit_temperature); + RUN_TEST(test_accepts_ds18b20_operating_range); + RUN_TEST(test_recognizes_resolution_configuration); + exit(UNITY_END()); +} + +void loop() {} diff --git a/variants/esp32s3/heltec_v3/platformio.ini b/variants/esp32s3/heltec_v3/platformio.ini index 2f53c8756..bd5aa8e83 100644 --- a/variants/esp32s3/heltec_v3/platformio.ini +++ b/variants/esp32s3/heltec_v3/platformio.ini @@ -18,3 +18,21 @@ build_flags = ${esp32s3_base.build_flags} -D HELTEC_V3 -I variants/esp32s3/heltec_v3 + +; Local custom build: one externally powered DS18B20. +; Kept separate from heltec-v3 so normal upstream Heltec builds do not include it. +[env:heltec-v3-ds18b20] +extends = env:heltec-v3 +board_level = extra + +build_flags = + ${env:heltec-v3.build_flags} + -D HAS_DS18B20=1 + -D DS18B20_PIN=7 + -D DS18B20_RESOLUTION_BITS=12 + -D DS18B20_SAMPLE_INTERVAL_MS=60000 + +lib_deps = + ${env:heltec-v3.lib_deps} + # renovate: datasource=github-tags depName=OneWire packageName=PaulStoffregen/OneWire + https://github.com/PaulStoffregen/OneWire/archive/refs/tags/v2.3.8.zip