From cb4863e4bdaa577e1fb039755647fd8ad6430395 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:18:56 +0200 Subject: [PATCH] T1000e full sensors --- zephcore/CMakeLists.txt | 4 + zephcore/adapters/sensors/SimpleLPP.h | 20 + .../adapters/sensors/ZephyrEnvSensors.cpp | 40 +- zephcore/adapters/sensors/ZephyrEnvSensors.h | 12 +- zephcore/adapters/sensors/t1000e_analog.c | 384 ++++++++++++++++++ zephcore/app/CompanionMesh.cpp | 18 +- zephcore/app/RepeaterMesh.cpp | 3 + zephcore/app/RoomServerMesh.cpp | 3 + .../nrf52840/t1000_e/t1000_e_nrf52840.dts | 45 +- .../bindings/sensor/seeed,t1000e-analog.yaml | 82 ++++ 10 files changed, 599 insertions(+), 12 deletions(-) create mode 100644 zephcore/adapters/sensors/t1000e_analog.c create mode 100644 zephcore/dts/bindings/sensor/seeed,t1000e-analog.yaml diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 7327bb6..e3371f2 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -588,6 +588,10 @@ target_sources(app PRIVATE adapters/clock/ZephyrRTCDiscover.c) # goertek,spa06 node is present. target_sources_ifdef(CONFIG_SENSOR app PRIVATE adapters/sensors/spa06.c) +# T1000-E onboard NTC + photocell on the SAADC. Same deal: compiles to nothing +# when no seeed,t1000e-analog node is present. +target_sources_ifdef(CONFIG_SENSOR app PRIVATE adapters/sensors/t1000e_analog.c) + # ========== Battery Curve Selection ========== # helpers/battery_curve.c is always compiled — it provides battery_curve_lookup() # and the weak battery_curve_default. A board-specific battery_curve.c is compiled diff --git a/zephcore/adapters/sensors/SimpleLPP.h b/zephcore/adapters/sensors/SimpleLPP.h index 771da6a..441c1c4 100644 --- a/zephcore/adapters/sensors/SimpleLPP.h +++ b/zephcore/adapters/sensors/SimpleLPP.h @@ -18,6 +18,7 @@ /* LPP Type Codes (from CayenneLPP spec) */ #define LPP_ANALOG_INPUT 2 /* 2 bytes, 0.01 signed */ +#define LPP_LUMINOSITY 101 /* 2 bytes, 1 lux unsigned */ #define LPP_TEMPERATURE 103 /* 2 bytes, 0.1°C signed */ #define LPP_RELATIVE_HUMIDITY 104 /* 1 byte, 0.5% unsigned */ #define LPP_BAROMETRIC_PRESSURE 115 /* 2 bytes, 0.1 hPa unsigned */ @@ -93,6 +94,25 @@ public: return addField1(channel, LPP_RELATIVE_HUMIDITY, val); } + /** + * Add luminosity reading + * + * The LPP type is nominally lux at 1-unit resolution, and a real + * ambient-light part gives lux. Boards whose light sensor reports a + * relative scale instead (the T1000-E photocell's 0-100) send that scale + * through unchanged — matching what Arduino MeshCore reports there, so a + * node reads the same after reflashing. + * + * @param channel Channel number + * @param value Luminosity, clamped to the 16-bit field + * @return Number of bytes written, or 0 on overflow + */ + uint8_t addLuminosity(uint8_t channel, float value) { + if (value < 0.0f) value = 0.0f; + if (value > 65535.0f) value = 65535.0f; + return addField2Unsigned(channel, LPP_LUMINOSITY, (uint16_t)value); + } + /** * Add barometric pressure reading * @param channel Channel number diff --git a/zephcore/adapters/sensors/ZephyrEnvSensors.cpp b/zephcore/adapters/sensors/ZephyrEnvSensors.cpp index edd1da6..dc9b244 100644 --- a/zephcore/adapters/sensors/ZephyrEnvSensors.cpp +++ b/zephcore/adapters/sensors/ZephyrEnvSensors.cpp @@ -4,9 +4,10 @@ * * Auto-detects available sensors via Zephyr devicetree nodelabels. * - * Environment sensors (temp/humidity/pressure): + * Environment sensors (temp/humidity/pressure/light): * SHTC3, AHT20/DHT20/AM2301B, SHT4x, SHT3xD, BME280, BME680, BMP280, BMP388, LPS22HB, SPA06 * MCU die temperature as fallback (nordic,nrf-temp) + * Board-local analog sensors (seeed,t1000e-analog: NTC thermistor + photocell) * * Power monitors (voltage/current/power): * INA219, INA3221, INA226, INA228, INA230, INA232, INA236, INA237 @@ -48,6 +49,7 @@ LOG_MODULE_REGISTER(zephcore_sensors, CONFIG_ZEPHCORE_SENSORS_LOG_LEVEL); #if HAS_ENV_SENSORS static const struct device *temp_humidity_dev = NULL; static const struct device *pressure_dev = NULL; +static const struct device *light_dev = NULL; static bool temp_dev_has_pressure = false; /* BME280/BME680 also have pressure */ static bool env_available = false; @@ -184,7 +186,20 @@ check_pressure: } done: - env_available = (temp_humidity_dev != NULL) || (pressure_dev != NULL); + /* === Board-local analog sensors === + * Not on any bus — a thermistor and a photocell wired straight to the + * SoC's ADC, so there is nothing to probe and the node's presence in DT + * is the whole detection. Its thermistor is read last in + * env_sensors_read() and only fills in a temperature nothing else + * supplied — a dedicated part beats a thermistor inside the case. */ + dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(t1000e_sensors)); + if (sensor_ready(dev)) { + light_dev = dev; + LOG_INF("Found analog sensors: %s (T1000-E NTC + photocell)", dev->name); + } + + env_available = (temp_humidity_dev != NULL) || (pressure_dev != NULL) || + (light_dev != NULL); if (!env_available) { LOG_INF("No environment sensors found"); } @@ -250,6 +265,25 @@ int env_sensors_read(struct env_data *data) } } + /* === Board-local analog sensors (light, and thermistor as fallback) === + * One fetch covers both channels — it switches the sensor rail, so + * splitting it would pay that cost twice. */ + if (light_dev) { + if (sensor_sample_fetch(light_dev) == 0) { + if (sensor_channel_get(light_dev, SENSOR_CHAN_LIGHT, &val) == 0) { + data->luminosity = sensor_value_to_float(&val); + data->has_luminosity = true; + } + /* Only where no bus sensor — nor a barometer's die + * channel above — already produced a temperature. */ + if (!data->has_temperature && + sensor_channel_get(light_dev, SENSOR_CHAN_AMBIENT_TEMP, &val) == 0) { + data->temperature_c = sensor_value_to_float(&val); + data->has_temperature = true; + } + } + } + /* === MCU die temperature — always read when available === * Used as fallback when no external temp sensor, and always * available via has_mcu_temperature for telemetry decisions. */ @@ -263,7 +297,7 @@ int env_sensors_read(struct env_data *data) } return (data->has_temperature || data->has_humidity || data->has_pressure || - data->has_mcu_temperature) ? 0 : -ENODATA; + data->has_luminosity || data->has_mcu_temperature) ? 0 : -ENODATA; #else return -ENOTSUP; #endif diff --git a/zephcore/adapters/sensors/ZephyrEnvSensors.h b/zephcore/adapters/sensors/ZephyrEnvSensors.h index 36e0dff..d182f1d 100644 --- a/zephcore/adapters/sensors/ZephyrEnvSensors.h +++ b/zephcore/adapters/sensors/ZephyrEnvSensors.h @@ -2,9 +2,10 @@ * SPDX-License-Identifier: MIT * Zephyr Environment & Power Sensors * - * Environment: temperature, humidity, pressure + * Environment: temperature, humidity, pressure, light * Supports: SHTC3, AHT20/DHT20/AM2301B, SHT4x, SHT3x, BME280, BME680, BMP280, BMP388, LPS22HB * MCU die temperature as fallback (nordic,nrf-temp) + * Board-local analog sensors: T1000-E NTC thermistor + photocell * * Power monitors: voltage, current, power * Supports: INA219, INA3221, INA226, INA228, INA230, INA232, INA236, INA237 @@ -27,12 +28,21 @@ struct env_data { float humidity_pct; /* Relative humidity in percent */ float pressure_hpa; /* Barometric pressure in hPa */ float mcu_temperature_c; /* MCU die temperature in Celsius */ + float luminosity; /* Ambient light — see note below */ bool has_temperature; bool has_humidity; bool has_pressure; bool has_mcu_temperature; + bool has_luminosity; }; +/* Note on luminosity: the unit is whatever the board's light sensor reports on + * SENSOR_CHAN_LIGHT, and it is forwarded to CayenneLPP luminosity unscaled. + * A true ambient-light part gives lux; the T1000-E's photocell gives Seeed's + * 0-100 scale, which Arduino MeshCore also reports verbatim. Keeping it + * unscaled is what makes a ZephCore node read the same as the stock firmware + * it replaced. */ + /* Initialize environment sensors (call once at boot) */ int env_sensors_init(void); diff --git a/zephcore/adapters/sensors/t1000e_analog.c b/zephcore/adapters/sensors/t1000e_analog.c new file mode 100644 index 0000000..509b850 --- /dev/null +++ b/zephcore/adapters/sensors/t1000e_analog.c @@ -0,0 +1,384 @@ +/* + * Seeed Tracker T1000-E onboard analog sensors — NTC thermistor + photocell + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: MIT + * + * Ported from Arduino MeshCore variants/t1000-e/t1000e_sensors.cpp, which in + * turn carries Seeed's own conversions. Both are reproduced here so a node + * reports the same numbers it did on the stock firmware. + */ + +#define DT_DRV_COMPAT seeed_t1000e_analog + +#include +#include +#include +#include +#include +#include +#include + +#if DT_HAS_COMPAT_STATUS_OKAY(DT_DRV_COMPAT) + +LOG_MODULE_REGISTER(t1000e_analog, CONFIG_SENSOR_LOG_LEVEL); + +/* Averaged per channel. The parts are slow and the rail is already paying a + * 10 ms settle, so a few extra conversions are free noise rejection. */ +#define T1000E_ADC_SAMPLES 4 + +/* ================================================================ + * NTC thermistor + * + * Resistance in ohms at each degree from -30 C (index 0) to +105 C + * (index 135) — a 10k-at-25C part, effective beta about 3250. Seeed's + * firmware carries a parallel array of temperatures, which is just the + * index minus 30, so only the resistances are stored here. + * + * (Seeed's source also defines a beta of 4250 next to this table. It is + * dead code there — nothing reads it — and it does not describe this + * curve, so do not "simplify" the table into a beta formula with it.) + * ================================================================ */ + +#define NTC_TABLE_LEN 136 +#define NTC_TABLE_T_MIN (-30) + +static const uint32_t ntc_res[NTC_TABLE_LEN] = { + 113347, 107565, 102116, 96978, 92132, 87559, 83242, 79166, 75316, 71677, + 68237, 64991, 61919, 59011, 56258, 53650, 51178, 48835, 46613, 44506, + 42506, 40600, 38791, 37073, 35442, 33892, 32420, 31020, 29689, 28423, + 27219, 26076, 24988, 23951, 22963, 22021, 21123, 20267, 19450, 18670, + 17926, 17214, 16534, 15886, 15266, 14674, 14108, 13566, 13049, 12554, + 12081, 11628, 11195, 10780, 10382, 10000, 9634, 9284, 8947, 8624, + 8315, 8018, 7734, 7461, 7199, 6948, 6707, 6475, 6253, 6039, + 5834, 5636, 5445, 5262, 5086, 4917, 4754, 4597, 4446, 4301, + 4161, 4026, 3896, 3771, 3651, 3535, 3423, 3315, 3211, 3111, + 3014, 2922, 2834, 2748, 2666, 2586, 2509, 2435, 2364, 2294, + 2228, 2163, 2100, 2040, 1981, 1925, 1870, 1817, 1766, 1716, + 1669, 1622, 1578, 1535, 1493, 1452, 1413, 1375, 1338, 1303, + 1268, 1234, 1202, 1170, 1139, 1110, 1081, 1053, 1026, 999, + 974, 949, 925, 902, 880, 858, +}; + +/* ================================================================ + * Photocell + * + * Seeed maps the divider voltage onto 0-100 with a dead band at each end. + * LIGHT_SPAN_MV is deliberately not (LIGHT_MAX_MV - LIGHT_MIN_MV): the + * stock firmware divides the 80..2480 mV range by 2400 while subtracting + * only the 80 mV floor, and the top of the range is clamped rather than + * reached. Reproduced as-is so readings match. + * ================================================================ */ + +#define LIGHT_MIN_MV 80 +#define LIGHT_MAX_MV 2480 +#define LIGHT_SPAN_MV 2400 + +struct t1000e_config { + struct adc_dt_spec ntc; + struct adc_dt_spec light; + struct adc_dt_spec vcc; + struct gpio_dt_spec power_gpio; + const struct device *power_supply; + uint32_t vcc_mv_multiplier; + uint32_t vcc_max_mv; + uint32_t ntc_series_ohms; + uint16_t settle_time_ms; +}; + +struct t1000e_data { + float temperature_c; + float light_pct; + bool temperature_valid; + bool light_valid; +}; + +/* Averaged raw reading for one channel, or a negative errno. */ +static int t1000e_read_raw(const struct adc_dt_spec *spec) +{ + int32_t total = 0; + int valid = 0; + + for (int i = 0; i < T1000E_ADC_SAMPLES; i++) { + int16_t sample = 0; + struct adc_sequence seq = { + .buffer = &sample, + .buffer_size = sizeof(sample), + }; + + if (adc_sequence_init_dt(spec, &seq) < 0) { + continue; + } + if (adc_read_dt(spec, &seq) == 0) { + total += sample; + valid++; + } + } + + if (valid == 0) { + return -EIO; + } + + total /= valid; + + /* A divider cannot swing below the rail's ground; a negative code is + * SAADC offset noise around zero, and would invert the conversions. */ + return (total < 0) ? 0 : (int)total; +} + +/* Millivolts at the pin, or a negative errno. */ +static int t1000e_read_mv(const struct adc_dt_spec *spec) +{ + int32_t mv = t1000e_read_raw(spec); + + if (mv < 0) { + return mv; + } + if (adc_raw_to_millivolts_dt(spec, &mv) < 0) { + return -EINVAL; + } + return (int)mv; +} + +static float t1000e_ntc_temperature(const struct t1000e_config *cfg, + uint32_t vcc_mv, uint32_t ntc_mv) +{ + float rp = (float)cfg->ntc_series_ohms; + float rt; + int i; + + /* Divider is rail - NTC - node - Rp - ground, so + * Vnode = Vcc * Rp / (Rp + Rntc) => Rntc = Rp * (Vcc - Vnode) / Vnode. + * A zero reading means an open NTC or an unpowered rail: infinitely + * cold on this curve, which the table floor turns into its low clamp. */ + if (ntc_mv == 0) { + return (float)NTC_TABLE_T_MIN; + } + + rt = rp * ((float)vcc_mv / (float)ntc_mv - 1.0f); + + /* Table is descending, so the first entry the resistance reaches or + * exceeds bounds it from above. */ + for (i = 0; i < NTC_TABLE_LEN; i++) { + if (rt >= (float)ntc_res[i]) { + break; + } + } + + /* Off either end of the curve. Seeed's loop indexes out of bounds in + * both of these cases; clamp instead. */ + if (i == 0) { + return (float)NTC_TABLE_T_MIN; + } + if (i == NTC_TABLE_LEN) { + return (float)(NTC_TABLE_T_MIN + NTC_TABLE_LEN - 1); + } + + /* Linear interpolation between the bracketing entries, which are + * exactly one degree apart. The 0.05 is Seeed's rounding compensation: + * every consumer downstream truncates to a tenth of a degree. */ + return (float)(NTC_TABLE_T_MIN + i - 1) + + ((float)ntc_res[i - 1] - rt) / + (float)(ntc_res[i - 1] - ntc_res[i]) + + 0.05f; +} + +static float t1000e_light_percent(uint32_t light_mv) +{ + if (light_mv <= LIGHT_MIN_MV) { + return 0.0f; + } + if (light_mv >= LIGHT_MAX_MV) { + return 100.0f; + } + return 100.0f * (float)(light_mv - LIGHT_MIN_MV) / (float)LIGHT_SPAN_MV; +} + +static int t1000e_power(const struct t1000e_config *cfg, bool on) +{ + int rc = 0; + + if (on) { + if (cfg->power_supply != NULL) { + rc = regulator_enable(cfg->power_supply); + if (rc < 0) { + return rc; + } + } + if (cfg->power_gpio.port != NULL) { + rc = gpio_pin_set_dt(&cfg->power_gpio, 1); + if (rc < 0) { + return rc; + } + } + k_msleep(cfg->settle_time_ms); + return 0; + } + + if (cfg->power_gpio.port != NULL) { + (void)gpio_pin_set_dt(&cfg->power_gpio, 0); + } + if (cfg->power_supply != NULL) { + (void)regulator_disable(cfg->power_supply); + } + return 0; +} + +static int t1000e_sample_fetch(const struct device *dev, + enum sensor_channel chan) +{ + const struct t1000e_config *cfg = dev->config; + struct t1000e_data *data = dev->data; + int ntc_mv, light_mv, vcc_raw; + uint32_t vcc_mv; + int rc; + + if (chan != SENSOR_CHAN_ALL && chan != SENSOR_CHAN_AMBIENT_TEMP && + chan != SENSOR_CHAN_LIGHT) { + return -ENOTSUP; + } + + rc = t1000e_power(cfg, true); + if (rc < 0) { + LOG_ERR("sensor rail power-up failed: %d", rc); + (void)t1000e_power(cfg, false); + return rc; + } + + ntc_mv = t1000e_read_mv(&cfg->ntc); + light_mv = t1000e_read_mv(&cfg->light); + vcc_raw = t1000e_read_raw(&cfg->vcc); + + (void)t1000e_power(cfg, false); + + /* The rail divider carries its own scaling, so it goes through the + * board's multiplier rather than the generic raw-to-millivolts helper. */ + if (vcc_raw < 0) { + vcc_mv = cfg->vcc_max_mv; + } else { + vcc_mv = ((uint32_t)vcc_raw * cfg->vcc_mv_multiplier) / 4096u; + if (vcc_mv > cfg->vcc_max_mv) { + vcc_mv = cfg->vcc_max_mv; + } + } + + data->temperature_valid = (ntc_mv >= 0); + if (data->temperature_valid) { + data->temperature_c = + t1000e_ntc_temperature(cfg, vcc_mv, (uint32_t)ntc_mv); + } + + data->light_valid = (light_mv >= 0); + if (data->light_valid) { + data->light_pct = t1000e_light_percent((uint32_t)light_mv); + } + + /* Millidegrees rather than a split integer/fraction pair: the naive + * split prints "-11.-75" below freezing, which is exactly where these + * readings most need checking. */ + LOG_DBG("ntc=%dmV light=%dmV vcc=%umV -> %d m°C, %d%%", + ntc_mv, light_mv, vcc_mv, + (int)(data->temperature_c * 1000.0f), + (int)data->light_pct); + + if (!data->temperature_valid && !data->light_valid) { + return -EIO; + } + return 0; +} + +static int t1000e_channel_get(const struct device *dev, + enum sensor_channel chan, + struct sensor_value *val) +{ + struct t1000e_data *data = dev->data; + + switch (chan) { + case SENSOR_CHAN_AMBIENT_TEMP: + if (!data->temperature_valid) { + return -ENODATA; + } + return sensor_value_from_float(val, data->temperature_c); + + case SENSOR_CHAN_LIGHT: + if (!data->light_valid) { + return -ENODATA; + } + return sensor_value_from_float(val, data->light_pct); + + default: + return -ENOTSUP; + } +} + +static DEVICE_API(sensor, t1000e_api) = { + .sample_fetch = t1000e_sample_fetch, + .channel_get = t1000e_channel_get, +}; + +static int t1000e_init(const struct device *dev) +{ + const struct t1000e_config *cfg = dev->config; + const struct adc_dt_spec *chans[] = { &cfg->ntc, &cfg->light, &cfg->vcc }; + int rc; + + for (size_t i = 0; i < ARRAY_SIZE(chans); i++) { + if (!adc_is_ready_dt(chans[i])) { + LOG_ERR("ADC %s not ready", chans[i]->dev->name); + return -ENODEV; + } + rc = adc_channel_setup_dt(chans[i]); + if (rc < 0) { + LOG_ERR("ADC channel %u setup failed: %d", + chans[i]->channel_id, rc); + return rc; + } + } + + if (cfg->power_supply != NULL && !device_is_ready(cfg->power_supply)) { + LOG_ERR("sensor rail regulator not ready"); + return -ENODEV; + } + + if (cfg->power_gpio.port != NULL) { + if (!gpio_is_ready_dt(&cfg->power_gpio)) { + LOG_ERR("sensor enable GPIO not ready"); + return -ENODEV; + } + rc = gpio_pin_configure_dt(&cfg->power_gpio, GPIO_OUTPUT_INACTIVE); + if (rc < 0) { + LOG_ERR("sensor enable GPIO config failed: %d", rc); + return rc; + } + } + + LOG_INF("T1000-E analog sensors ready (NTC + photocell)"); + return 0; +} + +#define T1000E_POWER_SUPPLY(inst) \ + COND_CODE_1(DT_INST_NODE_HAS_PROP(inst, power_supply), \ + (DEVICE_DT_GET(DT_INST_PHANDLE(inst, power_supply))), \ + (NULL)) + +#define T1000E_DEFINE(inst) \ + static struct t1000e_data t1000e_data_##inst; \ + static const struct t1000e_config t1000e_config_##inst = { \ + .ntc = ADC_DT_SPEC_INST_GET_BY_NAME(inst, ntc), \ + .light = ADC_DT_SPEC_INST_GET_BY_NAME(inst, light), \ + .vcc = ADC_DT_SPEC_INST_GET_BY_NAME(inst, vcc), \ + .power_gpio = GPIO_DT_SPEC_INST_GET_OR(inst, power_gpios, {0}),\ + .power_supply = T1000E_POWER_SUPPLY(inst), \ + .vcc_mv_multiplier = DT_INST_PROP(inst, vcc_mv_multiplier), \ + .vcc_max_mv = DT_INST_PROP(inst, vcc_max_mv), \ + .ntc_series_ohms = DT_INST_PROP(inst, ntc_series_ohms), \ + .settle_time_ms = DT_INST_PROP(inst, settle_time_ms), \ + }; \ + SENSOR_DEVICE_DT_INST_DEFINE(inst, t1000e_init, NULL, \ + &t1000e_data_##inst, \ + &t1000e_config_##inst, \ + POST_KERNEL, CONFIG_SENSOR_INIT_PRIORITY,\ + &t1000e_api); + +DT_INST_FOREACH_STATUS_OKAY(T1000E_DEFINE) + +#endif /* DT_HAS_COMPAT_STATUS_OKAY */ diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index 379370b..1cc87b8 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -1223,7 +1223,7 @@ bool CompanionMesh::vcontactHandleFrame(const uint8_t *data, size_t len) if (tag == 0) tag = 1; sendPacketSent(MSG_SEND_SENT_DIRECT, tag, 3000); - uint8_t rsp[8 + 4 + 11 + 11 + (12 * POWER_MAX_CHANNELS) + 8]; + uint8_t rsp[8 + 4 + 11 + 15 + (12 * POWER_MAX_CHANNELS) + 8]; int i = 0; rsp[i++] = PUSH_CODE_TELEMETRY_RESPONSE; rsp[i++] = 0; /* reserved */ @@ -1565,6 +1565,16 @@ int CompanionMesh::appendSelfTelemetry(uint8_t *reply, uint8_t permissions) reply[i++] = (press >> 8) & 0xFF; reply[i++] = press & 0xFF; } + if (env.has_luminosity) { + reply[i++] = CH_SELF; + reply[i++] = LPP_LUMINOSITY; + float lum = env.luminosity; + if (lum < 0.0f) lum = 0.0f; + if (lum > 65535.0f) lum = 65535.0f; + uint16_t lux = (uint16_t)lum; + reply[i++] = (lux >> 8) & 0xFF; + reply[i++] = lux & 0xFF; + } } // Power monitor telemetry (INA219/INA3221/ina2xx) @@ -3152,9 +3162,9 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) // Response: [PUSH_CODE_TELEMETRY_RESPONSE][reserved][6-byte pubkey][telemetry_data] // Worst-case size tracks POWER_MAX_CHANNELS so a future bump can't // silently overflow this stack buffer. With current value 4: - // header(8) + batt(4) + gps(11) + env(temp4+hum3+press4=11) - // + power(POWER_MAX_CHANNELS * 12 = 48) + 8 byte safety pad = 90. - uint8_t rsp[8 + 4 + 11 + 11 + (12 * POWER_MAX_CHANNELS) + 8]; + // header(8) + batt(4) + gps(11) + env(temp4+hum3+press4+lum4=15) + // + power(POWER_MAX_CHANNELS * 12 = 48) + 8 byte safety pad = 94. + uint8_t rsp[8 + 4 + 11 + 15 + (12 * POWER_MAX_CHANNELS) + 8]; int i = 0; rsp[i++] = PUSH_CODE_TELEMETRY_RESPONSE; rsp[i++] = 0; // reserved diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index 0574904..fb5b0b6 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -391,6 +391,9 @@ int RepeaterMesh::handleRequest(ClientInfo* sender, uint32_t sender_timestamp, u if (env.has_pressure) { lpp.addBarometricPressure(CH_SELF, env.pressure_hpa); } + if (env.has_luminosity) { + lpp.addLuminosity(CH_SELF, env.luminosity); + } } else { /* No env sensors at all — try MCU temp directly */ float mcu_temp = _board.getMCUTemperature(); diff --git a/zephcore/app/RoomServerMesh.cpp b/zephcore/app/RoomServerMesh.cpp index fed4b64..9f40c10 100644 --- a/zephcore/app/RoomServerMesh.cpp +++ b/zephcore/app/RoomServerMesh.cpp @@ -156,6 +156,9 @@ int RoomServerMesh::handleRequest(ClientInfo* sender, uint32_t sender_timestamp, if (env.has_pressure) { lpp.addBarometricPressure(CH_SELF, env.pressure_hpa); } + if (env.has_luminosity) { + lpp.addLuminosity(CH_SELF, env.luminosity); + } } else { /* No env sensors at all — try MCU temp directly */ float mcu_temp = _board.getMCUTemperature(); diff --git a/zephcore/boards/nrf52840/t1000_e/t1000_e_nrf52840.dts b/zephcore/boards/nrf52840/t1000_e/t1000_e_nrf52840.dts index 32f2c1c..0103227 100644 --- a/zephcore/boards/nrf52840/t1000_e/t1000_e_nrf52840.dts +++ b/zephcore/boards/nrf52840/t1000_e/t1000_e_nrf52840.dts @@ -105,6 +105,29 @@ vbat-mv-multiplier = <7236>; /* 2:1 divider, 3.6V ref; +0.5% for nRF SAADC gain error */ }; + /* Onboard NTC thermistor (AIN7) and photocell (AIN5). + * + * Both dividers hang off the sensor_power rail AND need SENSOR_EN + * (P0.4) asserted — Arduino drives both before every read. The driver + * switches them per sample fetch; regulator refcounting keeps that + * safe alongside the battery read, which shares the rail. + * + * The NTC conversion needs the rail voltage feeding its divider, so + * the battery channel is wired in here as "vcc" with the same + * multiplier zephyr,user uses. It is clamped to vcc-max-mv, which is + * where it sits for all but the flattest cell. */ + t1000e_sensors: analog-sensors { + compatible = "seeed,t1000e-analog"; + io-channels = <&adc 7>, <&adc 5>, <&adc 0>; + io-channel-names = "ntc", "light", "vcc"; + vcc-mv-multiplier = <7236>; + vcc-max-mv = <3300>; + ntc-series-ohms = <8250>; + settle-time-ms = <10>; + power-gpios = <&gpio0 4 GPIO_ACTIVE_HIGH>; + power-supply = <&sensor_power>; + }; + /* Buzzer on PWM0 channel 0 (P0.25) with enable on P1.05 */ pwmbuzzer { compatible = "pwm-leds"; @@ -227,7 +250,24 @@ }; }; -/* I2C0 for QMA6100P accelerometer + external sensors */ +/* I2C0 — QMA6100P accelerometer only, and it is not declared here. + * + * Deliberately does NOT include common/sensors-i2c.dtsi. This is a sealed + * tracker: there is no Grove/expansion header, so none of those parts can + * ever be attached, and Arduino MeshCore agrees — the t1000-e variant uses + * its own T1000SensorManager with no I2C environment sensors at all, unlike + * the boards that pull in EnvironmentSensorManager. + * + * Including the list cost ~3.5 s of every boot. Both rails feeding this bus + * (sensor_power on P1.6, and the accelerometer's own P1.7) are off at boot, + * so its pull-ups are unpowered and SDA/SCL sit low. A transfer to a dead + * bus never completes, so each of the seven phantom parts burned a full + * CONFIG_I2C_NRFX_TRANSFER_TIMEOUT (500 ms) instead of NAKing in + * microseconds the way it would on a live bus. + * + * The bus stays enabled so the accelerometer can be added as a single node + * later — but powering P1.7 is part of that change, not optional. + */ &i2c0 { compatible = "nordic,nrf-twim"; status = "okay"; @@ -235,9 +275,6 @@ pinctrl-0 = <&i2c0_default>; pinctrl-1 = <&i2c0_sleep>; pinctrl-names = "default", "sleep"; - - /* All supported environment & power sensors — auto-detected at runtime */ - #include "../../common/sensors-i2c.dtsi" }; /* SPI1 for LR1110 */ diff --git a/zephcore/dts/bindings/sensor/seeed,t1000e-analog.yaml b/zephcore/dts/bindings/sensor/seeed,t1000e-analog.yaml new file mode 100644 index 0000000..cdf3f2c --- /dev/null +++ b/zephcore/dts/bindings/sensor/seeed,t1000e-analog.yaml @@ -0,0 +1,82 @@ +# Copyright (c) 2026 ZephCore +# SPDX-License-Identifier: MIT + +description: | + Seeed Tracker T1000-E onboard analog sensors. + + Two passive parts sit on the board's switched 3V3 sensor rail and are read + through the SoC's ADC: + + - An NTC thermistor forming the upper leg of a divider, reported as + SENSOR_CHAN_AMBIENT_TEMP in degrees C. The resistance is converted + through the same resistance/temperature table Seeed's own firmware uses, + so readings match the stock and Arduino MeshCore builds. + + - A photocell, reported as SENSOR_CHAN_LIGHT. Seeed's firmware maps it to + a 0-100 scale rather than to lux, and Arduino MeshCore forwards that + scale verbatim as CayenneLPP luminosity, so this driver does the same. + The value is a percentage despite the channel's nominal lux units. + + Neither divider outputs a valid voltage until the rail regulator and the + sensor-enable GPIO are both asserted and given time to settle, so the driver + switches them around each sample fetch rather than leaving them on. + +compatible: "seeed,t1000e-analog" + +include: [sensor-device.yaml] + +properties: + io-channels: + required: true + description: | + ADC channels, in the order named by io-channel-names: "ntc", "light" and + "vcc". "vcc" is the battery/rail divider channel — converting the NTC + divider needs the rail voltage feeding it, not a constant. + + io-channel-names: + required: true + + vcc-mv-multiplier: + type: int + required: true + description: | + Scales the raw "vcc" reading to millivolts as mv = raw * multiplier / 4096. + Same convention as the zephyr,user vbat-mv-multiplier property, and + normally the same value, since it is normally the same channel. + + vcc-max-mv: + type: int + default: 3300 + description: | + Rail voltage ceiling in millivolts, used to clamp the measured "vcc" + before it becomes the NTC divider reference. Above the regulator's + dropout the divider is fed from the fixed rail rather than from the + cell, so the higher battery reading would overstate it. Only a cell + flat enough to drag the rail down reads below this. + + power-gpios: + type: phandle-array + description: | + Sensor-enable pin (SENSOR_EN, P0.4 on the T1000-E), asserted for the + duration of a sample fetch. This is in addition to power-supply: the + T1000-E gates these two sensors behind both. + + power-supply: + type: phandle + description: | + Regulator for the switched sensor rail (PIN_3V3_EN, P1.6 on the + T1000-E), enabled for the duration of a sample fetch. Refcounted, so it + nests safely with the battery read, which switches the same rail. + + settle-time-ms: + type: int + default: 10 + description: | + Delay between asserting the rail and enable pin and sampling, to let the + divider and its decoupling capacitor settle. + + ntc-series-ohms: + type: int + default: 8250 + description: | + Series resistor in the lower leg of the NTC divider, in ohms.