mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-04 03:18:10 +10:00
feat(telemetry): add configurable DS18B20 sensor
This commit is contained in:
@@ -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(<Adafruit_TSL2561_U.h>)
|
||||
#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<DS18B20GpioSensor>(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);
|
||||
|
||||
@@ -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 <cstring>
|
||||
|
||||
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<int32_t>(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<int32_t>(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<int32_t>(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
|
||||
@@ -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 <OneWire.h>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
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<uint8_t>(CONFIG_9_BIT | ((resolutionBits - 9) << 5));
|
||||
}
|
||||
|
||||
inline float decodeTemperature(const uint8_t scratchpad[9])
|
||||
{
|
||||
const uint16_t raw = static_cast<uint16_t>(scratchpad[0]) | (static_cast<uint16_t>(scratchpad[1]) << 8);
|
||||
return static_cast<float>(static_cast<int16_t>(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
|
||||
@@ -0,0 +1,52 @@
|
||||
#include "TestUtil.h"
|
||||
#include "modules/Telemetry/Sensor/DS18B20Protocol.h"
|
||||
#include <cstdlib>
|
||||
#include <unity.h>
|
||||
|
||||
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() {}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user