diff --git a/build.sh b/build.sh index d1ced24..4741045 100644 --- a/build.sh +++ b/build.sh @@ -10,6 +10,7 @@ nRF_boards=( rak3401_1watt wio_tracker_l1 t1000_e + meshtracker_x1 thinknode_m1 thinknode_m3 thinknode_m6 diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index b362e9f..9b61ec0 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -770,14 +770,25 @@ Shutdown. Single button; tap-count → key-code mapping comes from the board's devicetree `tap-codes` (up to 5). Typical mapping: - 1 tap → Page next - 2 taps → LED heartbeat toggle -- 3 taps → Buzzer toggle +- 3 taps → Notification mode (sound+vibrate → vibrate → silent → sound → …; boards with no motor fall back to a plain on/off toggle) - 4 taps → GPS toggle - 5 taps → Flood advert (immediate, no delay) -### 8.4 Buzzer +### 8.4 Buzzer and vibration Non-blocking RTTTL parser on dedicated work queue. Predefined melodies for startup, shutdown, messages, ACKs. 2-second safety watchdog auto-silences on work queue stall. +Boards with a DRV2605 haptic driver (`ti,drv2605` in DT) also vibrate on every notification — `buzzer_play()` pulses the motor. The two outputs share one setting, the notification mode, which lives in `helpers/buzzer_gate.c` (always linked, same pattern as `led_gate.c`, so the CLI resolves its symbols on boards that compile no buzzer). `set buzzer 0|1|2|3` and the 3-tap button action both drive it: + +| Mode | Name | Buzzer | Motor | +|------|------|--------|-------| +| 0 | silent | - | - | +| 1 | sound+vib | yes | yes | +| 2 | vibrate | - | yes | +| 3 | sound | yes | - | + +Modes 2 and 3 are rejected on boards with no motor, where they would be indistinguishable from 0 and 1. The setting persists in the existing `buzzer_quiet` prefs byte — 0 and 1 keep their original meaning, 2 and 3 are new and read as "quiet" by older firmware, so a downgrade silences a node left on sound-only. + ### 8.5 Doom Easter Egg Wolf3D-style raycaster on OLED: textured walls, 2 enemy types, shooting, HUD. Bypasses CFB, writes directly to display. ~1.7KB RAM, ~5KB flash. Enabled via `CONFIG_ZEPHCORE_EASTER_EGG_DOOM`. Button UI: triple-press ENTER on Messages page. Joystick UI: Tools menu → "Doom". @@ -852,6 +863,7 @@ Build strings and flash methods: `boards/supported_boards.md` and `boards/exampl | RAK3401 1W | nRF52840 | SX1262+SKY66122 (30dBm) | u-blox MAX-7Q (opt) | - | I2C sensors | | RAK WisMesh Tag | nRF52840 | SX1262 | AT6558R | - | Accelerometer, buzzer, multitap | | T1000-E | nRF52840 | **LR1110** | AG3335 | - | Buzzer, button, multitap | +| SenseCAP MeshTracker X1 | nRF52840 | **LR2021** | AG3335M (L1+L5) | - | SPA06 barometer, DRV2605L vibration, YSN8900 RTC, QSPI 8MB, RGB LEDs, buzzer | | ThinkNode M1 | nRF52840 | SX1262 | Air530Z | EPD 200x200 (SSD1681) | Buzzer, 2 buttons, QSPI 2MB, RGB LEDs | | ThinkNode M3 | nRF52840 | **LR1110** | Yes | - | Buzzer, 2 buttons, RGB LEDs | | ThinkNode M6 | nRF52840 | SX1262 | L76K | - | QSPI, RGB LEDs | diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 5f7731e..0776269 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -265,7 +265,7 @@ set(ZEPHCORE_COMMON_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/zephcore_com # Detect platform from board name and add platform-specific common config # For boards with qualifiers like "wio_tracker_l1/nrf52840", check both the full BOARD # string and the BOARD_QUALIFIERS which contains just the qualifier (e.g., "nrf52840") -if(BOARD MATCHES ".*nrf52.*" OR BOARD MATCHES "rak4631" OR BOARD MATCHES "rak_wismesh_tag" OR BOARD MATCHES "rak3401_1watt" OR BOARD MATCHES "wio_tracker" OR BOARD MATCHES "ikoka_nano" OR BOARD MATCHES "t1000_e" OR BOARD MATCHES "thinknode_m1" OR BOARD MATCHES "thinknode_m3" OR BOARD MATCHES "thinknode_m6" OR BOARD MATCHES "promicro_lr2021" OR BOARD MATCHES "promicro_sx1262" OR BOARD MATCHES "sensecap_solar" OR BOARD MATCHES "xiao_nrf52840" OR BOARD MATCHES "lilygo_techo" OR BOARD MATCHES "lilygo_timpulse_plus" OR BOARD MATCHES "heltec_t114" OR BOARD MATCHES "heltec_t096" OR BOARD MATCHES "gat562" OR BOARD MATCHES "muziworks_r1neo") +if(BOARD MATCHES ".*nrf52.*" OR BOARD MATCHES "rak4631" OR BOARD MATCHES "rak_wismesh_tag" OR BOARD MATCHES "rak3401_1watt" OR BOARD MATCHES "wio_tracker" OR BOARD MATCHES "ikoka_nano" OR BOARD MATCHES "t1000_e" OR BOARD MATCHES "meshtracker_x1" OR BOARD MATCHES "thinknode_m1" OR BOARD MATCHES "thinknode_m3" OR BOARD MATCHES "thinknode_m6" OR BOARD MATCHES "promicro_lr2021" OR BOARD MATCHES "promicro_sx1262" OR BOARD MATCHES "sensecap_solar" OR BOARD MATCHES "xiao_nrf52840" OR BOARD MATCHES "lilygo_techo" OR BOARD MATCHES "lilygo_timpulse_plus" OR BOARD MATCHES "heltec_t114" OR BOARD MATCHES "heltec_t096" OR BOARD MATCHES "gat562" OR BOARD MATCHES "muziworks_r1neo") set(ZEPHCORE_PLATFORM_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/nrf52_common.conf") elseif(DEFINED BOARD_QUALIFIERS AND BOARD_QUALIFIERS MATCHES ".*nrf52.*") set(ZEPHCORE_PLATFORM_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/nrf52_common.conf") @@ -571,6 +571,9 @@ target_sources(app PRIVATE # LED master gate ("set leds on|off"). Always compiled: helpers/ui/ui_common.c # only exists in UI builds, but a headless repeater still drives lora-tx-led. helpers/led_gate.c + # Notification-mode gate. Same reason as led_gate: the CLI needs these + # symbols on boards that compile no buzzer at all. + helpers/buzzer_gate.c ) # Boot-time hardware-RTC auto-discovery (compact raw-I2C). Always compiled so @@ -579,6 +582,10 @@ target_sources(app PRIVATE # present in DT. target_sources(app PRIVATE adapters/clock/ZephyrRTCDiscover.c) +# SPA06-003 barometer. No upstream Zephyr driver; compiles to nothing when no +# goertek,spa06 node is present. +target_sources_ifdef(CONFIG_SENSOR app PRIVATE adapters/sensors/spa06.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 @@ -876,6 +883,7 @@ endif() if(CONFIG_ZEPHCORE_UI_BUZZER) target_sources(app PRIVATE helpers/ui/buzzer.c + helpers/ui/haptic.c ) endif() diff --git a/zephcore/Kconfig b/zephcore/Kconfig index 388a40b..8948378 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -251,10 +251,16 @@ config ZEPHCORE_OFFLINE_QUEUE_SIZE config ZEPHCORE_ACK_TABLE_SIZE int "ACK tracking table size" - default 8 + default 16 help Number of pending ACKs that can be tracked. + Sizing this below the number of messages that can be in flight at + once costs delivery confirmations: a peer working through a backlog + can answer seconds later, and an entry evicted before its ACK + arrives leaves the message unconfirmed, prompting a retry that adds + further load. Each entry costs 16 bytes. + config ZEPHCORE_MAX_CONNECTIONS int "Maximum server connections" default 16 diff --git a/zephcore/adapters/sensors/ZephyrEnvSensors.cpp b/zephcore/adapters/sensors/ZephyrEnvSensors.cpp index cbf6dd3..b1ffd8d 100644 --- a/zephcore/adapters/sensors/ZephyrEnvSensors.cpp +++ b/zephcore/adapters/sensors/ZephyrEnvSensors.cpp @@ -5,7 +5,7 @@ * Auto-detects available sensors via Zephyr devicetree nodelabels. * * Environment sensors (temp/humidity/pressure): - * SHTC3, AHT20/DHT20/AM2301B, SHT4x, SHT3xD, BME280, BME680, BMP280, BMP388, LPS22HB + * SHTC3, AHT20/DHT20/AM2301B, SHT4x, SHT3xD, BME280, BME680, BMP280, BMP388, LPS22HB, SPA06 * MCU die temperature as fallback (nordic,nrf-temp) * * Power monitors (voltage/current/power): @@ -144,6 +144,18 @@ check_pressure: LOG_INF("Found pressure sensor: %s (BMP388)", dev->name); goto done; } + + /* SPA06 — the two nodes are the same part at its two possible + * addresses; the one that isn't there fails its ID check. */ + dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(spa06)); + if (!dev || !device_is_ready(dev)) { + dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(spa06_alt)); + } + if (dev && device_is_ready(dev)) { + pressure_dev = dev; + LOG_INF("Found pressure sensor: %s (SPA06)", dev->name); + goto done; + } } done: @@ -204,6 +216,13 @@ int env_sensors_read(struct env_data *data) data->pressure_hpa = sensor_value_to_float(&val) * 10.0f; data->has_pressure = true; } + /* Barometers carry a die temperature. It beats the MCU's own + * sensor as a fallback on boards with no dedicated temp part. */ + if (!data->has_temperature && + sensor_channel_get(pressure_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 === diff --git a/zephcore/adapters/sensors/spa06.c b/zephcore/adapters/sensors/spa06.c new file mode 100644 index 0000000..3fc5562 --- /dev/null +++ b/zephcore/adapters/sensors/spa06.c @@ -0,0 +1,244 @@ +/* + * Goertek SPA06-003 pressure + temperature sensor + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: MIT + */ + +#define DT_DRV_COMPAT goertek_spa06 + +#include +#include +#include +#include +#include + +#if DT_HAS_COMPAT_STATUS_OKAY(DT_DRV_COMPAT) + +LOG_MODULE_REGISTER(spa06, CONFIG_SENSOR_LOG_LEVEL); + +#define SPA06_REG_PSR_B2 0x00 +#define SPA06_REG_PRS_CFG 0x06 +#define SPA06_REG_TMP_CFG 0x07 +#define SPA06_REG_MEAS_CFG 0x08 +#define SPA06_REG_CFG_REG 0x09 +#define SPA06_REG_RESET 0x0C +#define SPA06_REG_ID 0x0D +#define SPA06_REG_COEF 0x10 + +#define SPA06_CHIP_ID 0x11 +#define SPA06_SOFT_RESET 0x09 + +#define SPA06_MEAS_COEF_RDY BIT(7) +#define SPA06_MEAS_SENSOR_RDY BIT(6) +#define SPA06_MEAS_CONT_BOTH 0x07 + +/* 8x oversampling for both channels: no result shift needed and the + * scaling factor below is the datasheet's kP/kT for that rate. */ +#define SPA06_OVERSAMPLE_8 0x03 +#define SPA06_RATE_1HZ 0x00 +#define SPA06_SCALE_FACTOR 7864320.0f + +#define SPA06_COEF_LEN 21 + +struct spa06_config { + struct i2c_dt_spec bus; +}; + +struct spa06_data { + int16_t c0, c1; + int32_t c00, c10; + int16_t c01, c11, c20, c21, c30; + int16_t c31, c40; + + int32_t raw_press; + int32_t raw_temp; +}; + +/* 24-bit two's complement, MSB first */ +static int32_t spa06_raw24(const uint8_t *buf) +{ + int32_t v = ((int32_t)buf[0] << 16) | ((int32_t)buf[1] << 8) | buf[2]; + + return (v & 0x800000) ? (v | ~0xFFFFFF) : v; +} + +static int16_t sign_extend_12(uint16_t v) +{ + return (v & 0x800) ? (int16_t)(v | 0xF000) : (int16_t)v; +} + +static int32_t sign_extend_20(uint32_t v) +{ + return (v & 0x80000) ? (int32_t)(v | 0xFFF00000) : (int32_t)v; +} + +static int spa06_read_coefficients(const struct device *dev) +{ + const struct spa06_config *cfg = dev->config; + struct spa06_data *data = dev->data; + uint8_t c[SPA06_COEF_LEN]; + int rc; + + rc = i2c_burst_read_dt(&cfg->bus, SPA06_REG_COEF, c, sizeof(c)); + if (rc < 0) { + return rc; + } + + data->c0 = sign_extend_12(((uint16_t)c[0] << 4) | (c[1] >> 4)); + data->c1 = sign_extend_12((((uint16_t)c[1] & 0x0F) << 8) | c[2]); + data->c00 = sign_extend_20(((uint32_t)c[3] << 12) | + ((uint32_t)c[4] << 4) | (c[5] >> 4)); + data->c10 = sign_extend_20((((uint32_t)c[5] & 0x0F) << 16) | + ((uint32_t)c[6] << 8) | c[7]); + data->c01 = (int16_t)(((uint16_t)c[8] << 8) | c[9]); + data->c11 = (int16_t)(((uint16_t)c[10] << 8) | c[11]); + data->c20 = (int16_t)(((uint16_t)c[12] << 8) | c[13]); + data->c21 = (int16_t)(((uint16_t)c[14] << 8) | c[15]); + data->c30 = (int16_t)(((uint16_t)c[16] << 8) | c[17]); + data->c31 = sign_extend_12(((uint16_t)c[18] << 4) | (c[19] >> 4)); + data->c40 = sign_extend_12((((uint16_t)c[19] & 0x0F) << 8) | c[20]); + + return 0; +} + +static int spa06_sample_fetch(const struct device *dev, + enum sensor_channel chan) +{ + const struct spa06_config *cfg = dev->config; + struct spa06_data *data = dev->data; + uint8_t buf[6]; + int rc; + + if (chan != SENSOR_CHAN_ALL && chan != SENSOR_CHAN_PRESS && + chan != SENSOR_CHAN_AMBIENT_TEMP) { + return -ENOTSUP; + } + + rc = i2c_burst_read_dt(&cfg->bus, SPA06_REG_PSR_B2, buf, sizeof(buf)); + if (rc < 0) { + return rc; + } + + data->raw_press = spa06_raw24(&buf[0]); + data->raw_temp = spa06_raw24(&buf[3]); + + return 0; +} + +static int spa06_channel_get(const struct device *dev, + enum sensor_channel chan, + struct sensor_value *val) +{ + struct spa06_data *data = dev->data; + float t_sc = (float)data->raw_temp / SPA06_SCALE_FACTOR; + float p_sc, p2, p3, p4, pa; + + switch (chan) { + case SENSOR_CHAN_AMBIENT_TEMP: + return sensor_value_from_float(val, + (float)data->c0 * 0.5f + + (float)data->c1 * t_sc); + + case SENSOR_CHAN_PRESS: + p_sc = (float)data->raw_press / SPA06_SCALE_FACTOR; + p2 = p_sc * p_sc; + p3 = p2 * p_sc; + p4 = p3 * p_sc; + + pa = (float)data->c00 + (float)data->c10 * p_sc + + (float)data->c20 * p2 + (float)data->c30 * p3 + + (float)data->c40 * p4 + + t_sc * ((float)data->c01 + (float)data->c11 * p_sc + + (float)data->c21 * p2 + (float)data->c31 * p3); + + /* Sensor API expects kPa */ + return sensor_value_from_float(val, pa / 1000.0f); + + default: + return -ENOTSUP; + } +} + +static DEVICE_API(sensor, spa06_api) = { + .sample_fetch = spa06_sample_fetch, + .channel_get = spa06_channel_get, +}; + +static int spa06_init(const struct device *dev) +{ + const struct spa06_config *cfg = dev->config; + uint8_t id, meas; + int rc; + + if (!i2c_is_ready_dt(&cfg->bus)) { + return -ENODEV; + } + + rc = i2c_reg_read_byte_dt(&cfg->bus, SPA06_REG_ID, &id); + if (rc < 0) { + return -ENODEV; + } + if (id != SPA06_CHIP_ID) { + LOG_DBG("no SPA06 at 0x%02x (id 0x%02x)", cfg->bus.addr, id); + return -ENODEV; + } + + rc = i2c_reg_write_byte_dt(&cfg->bus, SPA06_REG_RESET, SPA06_SOFT_RESET); + if (rc < 0) { + return rc; + } + k_msleep(15); + + /* Calibration data is only valid once the part reports both ready */ + for (int i = 0; i < 20; i++) { + rc = i2c_reg_read_byte_dt(&cfg->bus, SPA06_REG_MEAS_CFG, &meas); + if (rc == 0 && (meas & SPA06_MEAS_COEF_RDY) && + (meas & SPA06_MEAS_SENSOR_RDY)) { + goto ready; + } + k_msleep(10); + } + LOG_ERR("SPA06 not ready after reset"); + return -EIO; + +ready: + rc = spa06_read_coefficients(dev); + if (rc < 0) { + return rc; + } + + rc = i2c_reg_write_byte_dt(&cfg->bus, SPA06_REG_PRS_CFG, + (SPA06_RATE_1HZ << 4) | SPA06_OVERSAMPLE_8); + if (rc == 0) { + rc = i2c_reg_write_byte_dt(&cfg->bus, SPA06_REG_TMP_CFG, + (SPA06_RATE_1HZ << 4) | SPA06_OVERSAMPLE_8); + } + if (rc == 0) { + /* No result shift: only needed above 8x oversampling */ + rc = i2c_reg_write_byte_dt(&cfg->bus, SPA06_REG_CFG_REG, 0x00); + } + if (rc == 0) { + rc = i2c_reg_write_byte_dt(&cfg->bus, SPA06_REG_MEAS_CFG, + SPA06_MEAS_CONT_BOTH); + } + if (rc < 0) { + return rc; + } + + LOG_INF("SPA06 at 0x%02x ready", cfg->bus.addr); + return 0; +} + +#define SPA06_DEFINE(inst) \ + static struct spa06_data spa06_data_##inst; \ + static const struct spa06_config spa06_config_##inst = { \ + .bus = I2C_DT_SPEC_INST_GET(inst), \ + }; \ + SENSOR_DEVICE_DT_INST_DEFINE(inst, spa06_init, NULL, \ + &spa06_data_##inst, &spa06_config_##inst,\ + POST_KERNEL, CONFIG_SENSOR_INIT_PRIORITY,\ + &spa06_api); + +DT_INST_FOREACH_STATUS_OKAY(SPA06_DEFINE) + +#endif /* DT_HAS_COMPAT_STATUS_OKAY */ diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index 8b645fc..fbc8629 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -187,7 +187,6 @@ CompanionMesh::CompanionMesh(mesh::Radio &radio, mesh::MillisecondClock &ms, mes _offline_queue_count = 0; _sync_pending = false; memset(_ack_table, 0, sizeof(_ack_table)); - _ack_next_overwrite = 0; memset(_advert_paths, 0, sizeof(_advert_paths)); _next_advert_path_idx = 0; _sign_data = nullptr; @@ -652,13 +651,22 @@ void CompanionMesh::addPendingAck(uint32_t expected, int contact_idx) return; } } - // Table full — circular overwrite - int idx = _ack_next_overwrite; + // Table full — evict the entry that has been waiting longest. A rotating + // write cursor is not equivalent: it advances independently of when each + // slot was filled, so under a burst it can discard an ACK registered + // moments ago while a much older one survives. Whichever entry we drop + // can never be confirmed, so drop the one least likely to still be + // answered. + int idx = 0; + for (int i = 1; i < ACK_TABLE_SIZE; i++) { + if ((int32_t)(_ack_table[i].sent_time - _ack_table[idx].sent_time) < 0) { + idx = i; + } + } _ack_table[idx].expected_ack = expected; _ack_table[idx].contact_idx = contact_idx; _ack_table[idx].sent_time = now_ms; _ack_table[idx].active = true; - _ack_next_overwrite = (idx + 1) % ACK_TABLE_SIZE; } int CompanionMesh::findAndRemoveAck(uint32_t ack, uint32_t *out_sent_time) diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index 116800f..949be0a 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -57,7 +57,7 @@ #ifdef CONFIG_ZEPHCORE_ACK_TABLE_SIZE #define ACK_TABLE_SIZE CONFIG_ZEPHCORE_ACK_TABLE_SIZE #else -#define ACK_TABLE_SIZE 8 +#define ACK_TABLE_SIZE 16 #endif /* Recently-heard advert path slots */ @@ -387,7 +387,6 @@ private: bool active; }; AckEntry _ack_table[ACK_TABLE_SIZE]; - int _ack_next_overwrite; /* Advert path table for tracking recently heard nodes */ AdvertPath _advert_paths[ADVERT_PATH_TABLE_SIZE]; diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index 90f51d5..e66979e 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -192,12 +192,34 @@ uint8_t RepeaterMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t LOG_INF("Login success"); client->last_timestamp = sender_timestamp; client->last_activity = getRTCClock()->getCurrentTime(); - client->permissions &= ~0x03; - client->permissions |= perms; + /* Role assignment only ever escalates. putClient() hands back the + * existing entry for a known pubkey, so writing the role bits + * unconditionally lets a later guest login on a key that already holds + * admin silently strip those rights — the operator is then locked out + * of their own repeater with no way back in over the air. Revoking + * admin is a deliberate act and belongs to the CLI. */ + if (!client->isAdmin()) { + client->permissions &= ~0x03; + client->permissions |= perms; + } memcpy(client->shared_secret, secret, PUB_KEY_SIZE); - if (perms != PERM_ACL_GUEST) { - if (!dirty_contacts_expiry) dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + if (client->isAdmin()) { + /* Flush admin sessions now instead of leaving them on the lazy + * timer. The entry carries the shared secret this session's + * traffic is encrypted with; if power is lost before the timer + * fires, the client keeps a secret the repeater no longer knows + * and every later request decrypts to nothing, which presents as + * a wrong password or an unreachable node. A repeater is the + * device most likely to lose power unattended, so the few + * milliseconds are worth it. save() writes the whole ACL, so any + * other pending changes go out with it. */ + if (_store) { + acl.save(_store->getAclPath()); + dirty_contacts_expiry = 0; + } else if (!dirty_contacts_expiry) { + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + } } } diff --git a/zephcore/boards/example_board/README.md b/zephcore/boards/example_board/README.md index 06090e0..6cc4055 100644 --- a/zephcore/boards/example_board/README.md +++ b/zephcore/boards/example_board/README.md @@ -16,6 +16,7 @@ Supported Boards | XIAO nRF52840 | `west build -b xiao_nrf52840 zephcore` | UF2 drag-drop or `west flash` | | ProMicro SX1262 | `west build -b promicro_sx1262 zephcore` | UF2 drag-drop or `west flash` | | T1000-E | `west build -b t1000_e zephcore` | UF2 drag-drop or `west flash` | +| SenseCAP MeshTracker X1 | `west build -b meshtracker_x1 zephcore` | UF2 drag-drop or `west flash` | | ThinkNode M1 | `west build -b thinknode_m1 zephcore` | UF2 drag-drop or `west flash` | | ThinkNode M3 | `west build -b thinknode_m3 zephcore` | UF2 drag-drop or `west flash` | | ThinkNode M6 | `west build -b thinknode_m6 zephcore` | UF2 drag-drop or `west flash` | diff --git a/zephcore/boards/nrf52840/meshtracker_x1/Kconfig.meshtracker_x1 b/zephcore/boards/nrf52840/meshtracker_x1/Kconfig.meshtracker_x1 new file mode 100644 index 0000000..e85d227 --- /dev/null +++ b/zephcore/boards/nrf52840/meshtracker_x1/Kconfig.meshtracker_x1 @@ -0,0 +1,6 @@ +# Copyright (c) 2026 ZephCore +# +# SPDX-License-Identifier: MIT + +config BOARD_MESHTRACKER_X1 + select SOC_NRF52840_QIAA diff --git a/zephcore/boards/nrf52840/meshtracker_x1/battery_curve.c b/zephcore/boards/nrf52840/meshtracker_x1/battery_curve.c new file mode 100644 index 0000000..19fea10 --- /dev/null +++ b/zephcore/boards/nrf52840/meshtracker_x1/battery_curve.c @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: MIT + * + * MeshTracker X1 high-voltage LiPo OCV curve — 21 points at 5% steps. + * 11-point base linearly interpolated to 5% resolution. + * Index 0 = 100% (4290 mV), index 20 = 0% (3100 mV). + * + * 4.37 V cut-off cell, so the top of the curve sits ~100 mV above a + * conventional 4.2 V pack. + */ + +#include "battery_curve.h" + +static const uint16_t ocv_meshtracker_x1[21] = { + 4290, 4204, 4118, 4060, 4002, /* 100 .. 80% */ + 3946, 3889, 3844, 3798, 3763, /* 75 .. 55% */ + 3728, 3703, 3677, 3660, 3643, /* 50 .. 30% */ + 3619, 3594, 3547, 3500, 3300, /* 25 .. 5% */ + 3100, /* 0% */ +}; + +const battery_curve_t battery_curve_default = { + .ocv_mv = ocv_meshtracker_x1, + .num_points = 21, + .num_cells = 1, +}; diff --git a/zephcore/boards/nrf52840/meshtracker_x1/board.cmake b/zephcore/boards/nrf52840/meshtracker_x1/board.cmake new file mode 100644 index 0000000..c338f44 --- /dev/null +++ b/zephcore/boards/nrf52840/meshtracker_x1/board.cmake @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: MIT + +board_runner_args(jlink "--device=nRF52840_xxAA" "--speed=4000") +board_runner_args(pyocd "--target=nrf52840" "--frequency=4000000") + +include(${ZEPHYR_BASE}/boards/common/uf2.board.cmake) +include(${ZEPHYR_BASE}/boards/common/jlink.board.cmake) +include(${ZEPHYR_BASE}/boards/common/pyocd.board.cmake) diff --git a/zephcore/boards/nrf52840/meshtracker_x1/board.conf b/zephcore/boards/nrf52840/meshtracker_x1/board.conf new file mode 100644 index 0000000..c5089d0 --- /dev/null +++ b/zephcore/boards/nrf52840/meshtracker_x1/board.conf @@ -0,0 +1,23 @@ +# SenseCAP MeshTracker X1 (nRF52840 + LR2021) +# +# Hardware: +# - LR2021 LoRa on SPI2 (P0.12 CS, P1.10 RST, P0.07 BUSY, P1.01 DIO8/IRQ), TCXO 1.6V +# - Airoha AG3335M dual-band L1+L5 GNSS on UART0 (P0.13 TX, P0.14 RX) @ 115200 +# - SPA06-003 barometer (0x77) + DRV2605L haptic (0x5A) on I2C0 (P1.15/P1.14) +# - YSN8900 RTC (0x32) on I2C1 (P0.26 SDA, P0.27 SCL), rail enable P0.09 +# - GD25Q64E 8MB QSPI flash -> /ext, rail enable P0.15 +# - RGB LED (P0.03 R, P0.24 G, P0.28 B), buzzer (P0.25), button (P0.06) +# - Battery ADC on AIN0 (P0.02), 2.0x divider, enable P1.06, BQ24045 charger + +CONFIG_ZEPHCORE_BOARD_NAME="MeshTracker X1" + +CONFIG_BT_DIS_MODEL_NUMBER_STR="Seeed MeshTracker X1" + +# SoftDevice firmware ID (s140 v7.3.0 — Seeed bootloader) +CONFIG_ZEPHCORE_SD_FWID=0x0123 + +CONFIG_ZEPHCORE_RADIO_LR2021=y + +# DRV2605L vibration motor +CONFIG_HAPTICS=y +CONFIG_HAPTICS_DRV2605=y diff --git a/zephcore/boards/nrf52840/meshtracker_x1/board.yml b/zephcore/boards/nrf52840/meshtracker_x1/board.yml new file mode 100644 index 0000000..487e08f --- /dev/null +++ b/zephcore/boards/nrf52840/meshtracker_x1/board.yml @@ -0,0 +1,10 @@ +# Copyright (c) 2026 ZephCore +# +# SPDX-License-Identifier: MIT + +board: + name: meshtracker_x1 + full_name: Seeed SenseCAP MeshTracker X1 + vendor: seeed + socs: + - name: nrf52840 diff --git a/zephcore/boards/nrf52840/meshtracker_x1/meshtracker_x1_nrf52840-pinctrl.dtsi b/zephcore/boards/nrf52840/meshtracker_x1/meshtracker_x1_nrf52840-pinctrl.dtsi new file mode 100644 index 0000000..f13490e --- /dev/null +++ b/zephcore/boards/nrf52840/meshtracker_x1/meshtracker_x1_nrf52840-pinctrl.dtsi @@ -0,0 +1,118 @@ +/* + * Seeed SenseCAP MeshTracker X1 - Pin Control + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: MIT + */ + +&pinctrl { + /* GNSS UART - Airoha dual-band L1+L5 at 115200 baud */ + uart0_default: uart0_default { + group1 { + psels = ; + }; + group2 { + psels = ; + bias-pull-up; + }; + }; + + uart0_sleep: uart0_sleep { + group1 { + psels = , + ; + low-power-enable; + }; + }; + + /* I2C0 - SPA06 barometer + DRV2605L haptic driver */ + i2c0_default: i2c0_default { + group1 { + psels = , + ; + }; + }; + + i2c0_sleep: i2c0_sleep { + group1 { + psels = , + ; + low-power-enable; + }; + }; + + /* I2C1 - YSN8900 RTC on its own bus */ + i2c1_default: i2c1_default { + group1 { + psels = , + ; + }; + }; + + i2c1_sleep: i2c1_sleep { + group1 { + psels = , + ; + low-power-enable; + }; + }; + + /* QSPI - GD25Q64E 8MB flash */ + qspi_default: qspi_default { + group1 { + psels = , + , + , + , + , + ; + }; + }; + + qspi_sleep: qspi_sleep { + group1 { + psels = , + , + , + , + ; + low-power-enable; + }; + group2 { + psels = ; + low-power-enable; + bias-pull-up; + }; + }; + + /* PWM0 for buzzer on P0.25 */ + pwm0_default: pwm0_default { + group1 { + psels = ; + }; + }; + + pwm0_sleep: pwm0_sleep { + group1 { + psels = ; + low-power-enable; + }; + }; + + /* SPI2 for LR2021 (SPI1 shares its peripheral slot with I2C1) */ + spi2_default: spi2_default { + group1 { + psels = , + , + ; + }; + }; + + spi2_sleep: spi2_sleep { + group1 { + psels = , + , + ; + low-power-enable; + }; + }; +}; diff --git a/zephcore/boards/nrf52840/meshtracker_x1/meshtracker_x1_nrf52840.dts b/zephcore/boards/nrf52840/meshtracker_x1/meshtracker_x1_nrf52840.dts new file mode 100644 index 0000000..ca7ac05 --- /dev/null +++ b/zephcore/boards/nrf52840/meshtracker_x1/meshtracker_x1_nrf52840.dts @@ -0,0 +1,380 @@ +/* + * Seeed SenseCAP MeshTracker X1 - nRF52840 + LR2021 + * Copyright (c) 2026 ZephCore + * + * SPDX-License-Identifier: MIT + * + * Hardware: + * - nRF52840 SoC with BLE + * - LR2021 LoRa radio (TCXO 1.6V on DIO3, no external RF switch) + * - Airoha dual-band L1+L5 GNSS on UART0 + * - SPA06 barometer + DRV2605L haptic driver (I2C0) + * - RGB LED, buzzer, single user button + * - 1100 mAh high-voltage LiPo, ADC on AIN0 + */ + +/dts-v1/; +#include +#include +#include +#include +#include +#include "meshtracker_x1_nrf52840-pinctrl.dtsi" + +/* Delete default partitions from nrf52840_partition.dtsi */ +/delete-node/ &boot_partition; +/delete-node/ &slot0_partition; +/delete-node/ &slot1_partition; +/delete-node/ &storage_partition; + +/ { + model = "Seeed SenseCAP MeshTracker X1"; + compatible = "seeed,meshtracker-x1"; + + chosen { + zephyr,code-partition = &code_partition; + zephyr,console = &cdc_acm_uart; + zephyr,shell-uart = &cdc_acm_uart; + }; + + leds { + compatible = "gpio-leds"; + + led_green: led_0 { + gpios = <&gpio0 24 GPIO_ACTIVE_HIGH>; + label = "Green LED"; + }; + + led_red: led_1 { + gpios = <&gpio0 3 GPIO_ACTIVE_HIGH>; + label = "Red LED"; + }; + + led_blue: led_2 { + gpios = <&gpio0 28 GPIO_ACTIVE_HIGH>; + label = "Blue LED"; + }; + }; + + buttons: buttons { + compatible = "gpio-keys"; + + user_button: button_0 { + /* Active HIGH (pressed = VCC), external pull-down on board */ + gpios = <&gpio0 6 GPIO_ACTIVE_HIGH>; + zephyr,code = ; + label = "User Button"; + }; + }; + + /* Short-press → KEY_A, long-press → KEY_F */ + user_btn_longpress { + compatible = "zephyr,input-longpress"; + input = <&buttons>; + input-codes = ; + short-codes = ; + long-codes = ; + long-delay-ms = <1000>; + }; + + /* 1 tap → page next, 2 → LED heartbeat, 3 → buzzer mute, + * 4 → GPS on/off, 5 → flood advert */ + user_btn_multitap { + compatible = "zephcore,input-multi-tap"; + input-codes = ; + tap-codes = ; + tap-delay-ms = <400>; + }; + + /* P1.07 — 3V3 rail feeding the I2C sensors (barometer, haptic driver) */ + sensor_power: sensor-power { + compatible = "regulator-fixed"; + regulator-name = "sensor-power"; + enable-gpios = <&gpio1 7 GPIO_ACTIVE_HIGH>; + regulator-boot-on; + startup-delay-us = <5000>; + }; + + /* P1.06 — gates the battery ADC divider, enabled on demand */ + vbat_enable: vbat-enable { + compatible = "regulator-fixed"; + regulator-name = "vbat-enable"; + enable-gpios = <&gpio1 6 GPIO_ACTIVE_HIGH>; + }; + + /* Battery ADC channel reference for ZephyrBoard */ + zephyr,user { + io-channels = <&adc 0>; + vbat-mv-multiplier = <7236>; /* 2:1 divider, 3.6V ref; +0.5% for nRF SAADC gain error */ + }; + + /* Buzzer on PWM0 channel 0 (P0.25) */ + pwmbuzzer { + compatible = "pwm-leds"; + buzzer: buzzer { + pwms = <&pwm0 0 PWM_MSEC(20) PWM_POLARITY_NORMAL>; + }; + }; + + /* GNSS control pins */ + gps_en: gps-enable { + compatible = "gpio-leds"; + gps_enable_pin: gps_enable { + gpios = <&gpio1 11 GPIO_ACTIVE_HIGH>; + label = "GPS Enable"; + }; + }; + + gps_vrtc: gps-vrtc-enable { + compatible = "gpio-leds"; + gps_vrtc_pin: gps_vrtc { + gpios = <&gpio1 13 GPIO_ACTIVE_HIGH>; + label = "GPS VRTC Enable"; + }; + }; + + gps_rst: gps-reset { + compatible = "gpio-leds"; + gps_reset_pin: gps_reset { + gpios = <&gpio0 8 GPIO_ACTIVE_HIGH>; + label = "GPS Reset"; + }; + }; + + gps_sleep: gps-sleep-int { + compatible = "gpio-leds"; + gps_sleep_pin: gps_sleep { + gpios = <&gpio0 30 GPIO_ACTIVE_HIGH>; + label = "GPS Sleep Int"; + }; + }; + + /* Held LOW during normal operation, HIGH wakes the GNSS RTC domain */ + gps_rtcint: gps-rtc-int { + compatible = "gpio-leds"; + gps_rtcint_pin: gps_rtc_int { + gpios = <&gpio0 29 GPIO_ACTIVE_HIGH>; + label = "GPS RTC Int"; + }; + }; + + aliases { + led0 = &led_green; + led1 = &led_red; + sw0 = &user_button; + watchdog0 = &wdt0; + buzzer = &buzzer; + lora0 = &lora; + gps-enable = &gps_enable_pin; + gps-vrtc-enable = &gps_vrtc_pin; + gps-reset = &gps_reset_pin; + gps-sleep-int = &gps_sleep_pin; + gps-rtc-int = &gps_rtcint_pin; + }; +}; + +®0 { + status = "okay"; +}; + +®1 { + regulator-initial-mode = ; +}; + +&adc { + status = "okay"; + #address-cells = <1>; + #size-cells = <0>; + + /* Battery voltage on P0.02 (AIN0) - 2x voltage divider */ + channel@0 { + reg = <0>; + zephyr,gain = "ADC_GAIN_1_6"; + zephyr,reference = "ADC_REF_INTERNAL"; + zephyr,acquisition-time = ; + zephyr,input-positive = ; + zephyr,resolution = <12>; + }; +}; + +/* P0.09/P0.10 are the NFC antenna pins — the board wires P0.09 to the RTC + * rail's power switch */ +&uicr { + nfct-pins-as-gpios; +}; + +&gpiote { + status = "okay"; +}; + +/* Both rails are hogged rather than modelled as regulators: a hog is applied + * at PRE_KERNEL_1, ahead of the QSPI NOR driver and the RTC probe, and neither + * rail is ever switched off — cutting the RTC would lose wall-clock time. */ +&gpio0 { + status = "okay"; + + /* RTC power switch (P0.09) */ + rtc-power-on { + gpio-hog; + gpios = <9 GPIO_ACTIVE_HIGH>; + output-high; + }; + + /* QSPI flash power switch (P0.15) */ + flash-power-on { + gpio-hog; + gpios = <15 GPIO_ACTIVE_HIGH>; + output-high; + }; +}; + +&gpio1 { + status = "okay"; +}; + +&pwm0 { + status = "okay"; + pinctrl-0 = <&pwm0_default>; + pinctrl-1 = <&pwm0_sleep>; + pinctrl-names = "default", "sleep"; +}; + +/* Airoha dual-band GNSS on UART0 - 115200 baud. + * Passive NMEA listener: the module streams NMEA at factory defaults, so no + * PAIR command exchange is needed and driver init cannot block on a timeout. */ +&uart0 { + compatible = "nordic,nrf-uarte"; + status = "okay"; + current-speed = <115200>; + pinctrl-0 = <&uart0_default>; + pinctrl-1 = <&uart0_sleep>; + pinctrl-names = "default", "sleep"; + + gnss: gnss { + compatible = "gnss-nmea-generic"; + /* ZephyrGPSManager powers the module up, then calls device_init() */ + zephyr,deferred-init; + }; +}; + +&i2c0 { + compatible = "nordic,nrf-twim"; + status = "okay"; + clock-frequency = ; + pinctrl-0 = <&i2c0_default>; + pinctrl-1 = <&i2c0_sleep>; + pinctrl-names = "default", "sleep"; + + /* Sealed enclosure — only these two parts are ever on this bus */ + spa06: spa06@77 { + compatible = "goertek,spa06"; + reg = <0x77>; + }; + + haptic: drv2605@5a { + compatible = "ti,drv2605"; + reg = <0x5a>; + actuator-mode = "LRA"; + en-gpios = <&gpio1 5 GPIO_ACTIVE_HIGH>; + }; +}; + +/* I2C1 — YSN8900 RTC, on its own bus and its own switched rail. + * + * The part is an RX8900 second-source (same 3.2x2.5x1.0 package, same + * 32.768 kHz TCXO grades), so it uses the RX8900 register layout rather than + * the RX8130CE one that rtc-i2c.dtsi assumes for address 0x32: time at 0x00, + * FLAG at 0x0E, VLF in bit 1. Boot-time discovery BCD-validates what it reads, + * so a wrong guess here is detected and skipped, not silently trusted. */ +&i2c1 { + compatible = "nordic,nrf-twim"; + status = "okay"; + clock-frequency = ; + pinctrl-0 = <&i2c1_default>; + pinctrl-1 = <&i2c1_sleep>; + pinctrl-names = "default", "sleep"; + + rtc_ysn8900: rtc-ysn8900@32 { + compatible = "zephcore,rtc-i2c"; + reg = <0x32>; + time-reg = <0x00>; + date-index = <4>; + status-reg = <0x0e>; + status-mask = <0x02>; + }; +}; + +/* GD25Q64E — 8MB, all of it LittleFS for contacts, channels and blobs */ +&qspi { + status = "okay"; + pinctrl-0 = <&qspi_default>; + pinctrl-1 = <&qspi_sleep>; + pinctrl-names = "default", "sleep"; + + qspi_flash: qspi_flash@0 { + compatible = "nordic,qspi-nor"; + reg = <0>; + writeoc = "pp4io"; + readoc = "read4io"; + sck-frequency = <16000000>; + jedec-id = [c8 40 17]; + size = <67108864>; /* 64Mbit = 8MB, in bits */ + has-dpd; + t-enter-dpd = <3000>; + t-exit-dpd = <30000>; + }; +}; + +/* SPI2 for LR2021 (SPI1 shares its peripheral slot with I2C1) */ +&spi2 { + compatible = "nordic,nrf-spim"; + status = "okay"; + cs-gpios = <&gpio0 12 GPIO_ACTIVE_LOW>; + pinctrl-0 = <&spi2_default>; + pinctrl-1 = <&spi2_sleep>; + pinctrl-names = "default", "sleep"; + + lora: lora@0 { + compatible = "semtech,lr2021"; + reg = <0>; + spi-max-frequency = <8000000>; + + reset-gpios = <&gpio1 10 GPIO_ACTIVE_LOW>; + busy-gpios = <&gpio0 7 GPIO_ACTIVE_HIGH>; + dio1-gpios = <&gpio1 1 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>; + irq-dio = <8>; + + tcxo-voltage-mv = <1600>; + tcxo-startup-delay-ms = <5>; + rx-boosted; + + /* Single antenna path, no DIO-driven RF switch on this board */ + }; +}; + +zephyr_udc0: &usbd { + compatible = "nordic,nrf-usbd"; + status = "okay"; + + cdc_acm_uart: cdc_acm_uart { + compatible = "zephyr,cdc-acm-uart"; + }; +}; + +#include "../../common/nrf52_partitions_sdv7.dtsi" + +&qspi_flash { + partitions { + compatible = "fixed-partitions"; + #address-cells = <1>; + #size-cells = <1>; + + qspi_storage_partition: partition@0 { + label = "qspi_storage"; + reg = <0x00000000 0x00800000>; + }; + }; +}; + +#include "../../common/qspi-ext.dtsi" +#include "../../common/nrf52_wakeup.dtsi" diff --git a/zephcore/boards/nrf52840/meshtracker_x1/meshtracker_x1_nrf52840.yaml b/zephcore/boards/nrf52840/meshtracker_x1/meshtracker_x1_nrf52840.yaml new file mode 100644 index 0000000..4748a03 --- /dev/null +++ b/zephcore/boards/nrf52840/meshtracker_x1/meshtracker_x1_nrf52840.yaml @@ -0,0 +1,21 @@ +identifier: meshtracker_x1 +name: Seeed SenseCAP MeshTracker X1 +type: mcu +arch: arm +ram: 256 +flash: 1024 +toolchain: + - zephyr + - gnuarmemb + - xtools +supported: + - adc + - ble + - gpio + - i2c + - nvs + - pwm + - spi + - usb_device + - watchdog +vendor: seeed diff --git a/zephcore/boards/nrf52840/meshtracker_x1/meshtracker_x1_nrf52840_defconfig b/zephcore/boards/nrf52840/meshtracker_x1/meshtracker_x1_nrf52840_defconfig new file mode 100644 index 0000000..d7d22af --- /dev/null +++ b/zephcore/boards/nrf52840/meshtracker_x1/meshtracker_x1_nrf52840_defconfig @@ -0,0 +1,11 @@ +# Copyright (c) 2026 ZephCore +# SPDX-License-Identifier: MIT + +CONFIG_ARM_MPU=y +CONFIG_HW_STACK_PROTECTION=y +CONFIG_GPIO=y +CONFIG_SERIAL=y +CONFIG_CONSOLE=y + +CONFIG_BUILD_OUTPUT_UF2=y +CONFIG_USE_DT_CODE_PARTITION=y diff --git a/zephcore/boards/nrf52840/promicro_lr2021/promicro_lr2021.dts b/zephcore/boards/nrf52840/promicro_lr2021/promicro_lr2021.dts index a502577..526eeaf 100644 --- a/zephcore/boards/nrf52840/promicro_lr2021/promicro_lr2021.dts +++ b/zephcore/boards/nrf52840/promicro_lr2021/promicro_lr2021.dts @@ -138,6 +138,7 @@ reset-gpios = <&gpio0 9 GPIO_ACTIVE_LOW>; busy-gpios = <&gpio0 29 GPIO_ACTIVE_HIGH>; dio1-gpios = <&gpio0 10 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>; + irq-dio = <9>; /* chip DIO9 → module pin 15 → MCU P0.10 */ /* TCXO: tried 1800mV on DIO3 (per Meshtastic diy_tcxo) — it WEDGED * the chip (HF-XOSC startup hangs, BUSY stuck, BLE never starts), diff --git a/zephcore/boards/supported_boards.md b/zephcore/boards/supported_boards.md index 2fd0da9..d3957db 100644 --- a/zephcore/boards/supported_boards.md +++ b/zephcore/boards/supported_boards.md @@ -9,6 +9,7 @@ rak4631 rak3401_1watt wio_tracker_l1 t1000_e +meshtracker_x1 thinknode_m1 thinknode_m3 thinknode_m6 @@ -28,6 +29,8 @@ muziworks_r1neo > > **Heltec T114** screenless build: append `boards/nrf52840/heltec_t114/no_display.conf` to `EXTRA_CONF_FILE` for units without the TFT module. > +> **SenseCAP MeshTracker X1** (`meshtracker_x1`): nRF52840 + LR2021, AG3335M dual-band L1+L5 GNSS, SPA06 barometer, DRV2605L vibration, YSN8900 RTC, 8 MB QSPI flash (`/ext`), RGB LED, buzzer. Untested on hardware — first ZephCore board to use a real LR2021. The RTC is treated as an RX8900 second-source; boot-time discovery validates that before trusting it. +> > **Heltec Mesh Node T096** (`heltec_t096`): nRF52840 with SX1262 + KCT8103L PA/FEM, UC6580 GNSS, and ST7735S 160x80 TFT companion display. The external SPI flash footprint is documented in the board notes but left disabled until the device parameters are confirmed. ## ESP32 diff --git a/zephcore/dts/bindings/sensor/goertek,spa06.yaml b/zephcore/dts/bindings/sensor/goertek,spa06.yaml new file mode 100644 index 0000000..9bf6261 --- /dev/null +++ b/zephcore/dts/bindings/sensor/goertek,spa06.yaml @@ -0,0 +1,13 @@ +# Copyright (c) 2026 ZephCore +# SPDX-License-Identifier: MIT + +description: | + Goertek SPA06-003 barometric pressure and temperature sensor. + + The driver runs the part in continuous background mode at 8x oversampling + and 1 Hz for both channels, so sample_fetch never blocks. Channels are + SENSOR_CHAN_PRESS (kPa) and SENSOR_CHAN_AMBIENT_TEMP (degrees C). + +compatible: "goertek,spa06" + +include: [sensor-device.yaml, i2c-device.yaml] diff --git a/zephcore/dts/bindings/vendor-prefixes.txt b/zephcore/dts/bindings/vendor-prefixes.txt index 26da973..70c9b2c 100644 --- a/zephcore/dts/bindings/vendor-prefixes.txt +++ b/zephcore/dts/bindings/vendor-prefixes.txt @@ -1 +1,2 @@ +goertek Goertek Inc. zephcore ZephCore diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index e55f147..ca2eea0 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -6,6 +6,8 @@ #include "CommonCLI.h" #include "battery_curve.h" #include "led_gate.h" +#include "buzzer_gate.h" +#include #include #include #include @@ -471,6 +473,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->interference_threshold); } else if (memcmp(config, "leds", 4) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->leds_disabled ? "off" : "on"); +#ifndef ZEPHCORE_REPEATER + } else if (memcmp(config, "buzzer", 6) == 0) { + uint8_t mode = zephcore_buzzer_mode_from_prefs(_prefs->buzzer_quiet); + snprintf(reply, CLI_REPLY_SIZE, "> %u (%s)", mode, + zephcore_buzzer_mode_name(mode)); +#endif } else if (memcmp(config, "agc.reset.interval", 18) == 0) { strcpy(reply, "Removed - use rxduty instead"); } else if (memcmp(config, "multi.acks", 10) == 0) { @@ -668,6 +676,37 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch savePrefs(); strcpy(reply, "OK"); } +#ifndef ZEPHCORE_REPEATER + } else if (memcmp(config, "buzzer ", 7) == 0) { + /* 0 = silent, 1 = sound + vibration, 2 = vibration only, + * 3 = sound only. Modes 2 and 3 only mean something on a board + * with a vibration motor, which most boards don't have. */ + const char* val = &config[7]; + int mode; + if (memcmp(val, "vibrate", 7) == 0 || val[0] == '2') { + mode = ZEPHCORE_BUZZER_VIBRATE; + } else if (memcmp(val, "sound", 5) == 0 || val[0] == '3') { + mode = ZEPHCORE_BUZZER_SOUND; + } else if (memcmp(val, "on", 2) == 0 || val[0] == '1') { + mode = ZEPHCORE_BUZZER_ON; + } else if (memcmp(val, "off", 3) == 0 || val[0] == '0') { + mode = ZEPHCORE_BUZZER_OFF; + } else { + mode = -1; + } + if (mode < 0) { + strcpy(reply, "Error: 0 (silent), 1 (sound+vib), 2 (vibrate) or 3 (sound)"); + } else if ((mode == ZEPHCORE_BUZZER_VIBRATE || mode == ZEPHCORE_BUZZER_SOUND) && + !zephcore_buzzer_has_vibrate()) { + strcpy(reply, "Error: no vibration motor on this board - use 0 or 1"); + } else { + _prefs->buzzer_quiet = zephcore_buzzer_prefs_from_mode((uint8_t)mode); + zephcore_buzzer_set_mode((uint8_t)mode, false); + ui_set_buzzer_mode((uint8_t)mode); + savePrefs(); + strcpy(reply, "OK"); + } +#endif } else if (memcmp(config, "agc.reset.interval ", 19) == 0) { /* Periodic AGC recalibration was removed: it reset the noise floor * to its unseeded sentinel on every fire, forcing a fresh seed and diff --git a/zephcore/helpers/buzzer_gate.c b/zephcore/helpers/buzzer_gate.c new file mode 100644 index 0000000..b50887a --- /dev/null +++ b/zephcore/helpers/buzzer_gate.c @@ -0,0 +1,91 @@ +/* + * ZephCore - Notification mode (buzzer + vibration) + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: MIT + */ + +#include "buzzer_gate.h" + +#include +#include + +/* Written from the main thread (boot / CLI) and the UI task, read by the + * render path — same split as led_gate. */ +static atomic_t s_mode = ATOMIC_INIT(ZEPHCORE_BUZZER_ON); + +uint8_t zephcore_buzzer_mode(void) +{ + return (uint8_t)atomic_get(&s_mode); +} + +void zephcore_buzzer_set_mode(uint8_t mode, bool deferred) +{ + if (mode > ZEPHCORE_BUZZER_MODE_MAX) { + mode = ZEPHCORE_BUZZER_ON; + } + atomic_set(&s_mode, (atomic_val_t)mode); + zephcore_buzzer_apply(mode, deferred); +} + +uint8_t zephcore_buzzer_mode_from_prefs(uint8_t prefs_byte) +{ + switch (prefs_byte) { + case 0: return ZEPHCORE_BUZZER_ON; + case 2: return ZEPHCORE_BUZZER_VIBRATE; + case 3: return ZEPHCORE_BUZZER_SOUND; + default: return ZEPHCORE_BUZZER_OFF; + } +} + +uint8_t zephcore_buzzer_prefs_from_mode(uint8_t mode) +{ + switch (mode) { + case ZEPHCORE_BUZZER_ON: return 0; + case ZEPHCORE_BUZZER_VIBRATE: return 2; + case ZEPHCORE_BUZZER_SOUND: return 3; + default: return 1; + } +} + +uint8_t zephcore_buzzer_next_mode(uint8_t mode) +{ + if (!zephcore_buzzer_has_vibrate()) { + /* VIBRATE is dead and SOUND is just ON, so it is a plain toggle */ + return (mode == ZEPHCORE_BUZZER_ON) ? ZEPHCORE_BUZZER_OFF + : ZEPHCORE_BUZZER_ON; + } + + switch (mode) { + case ZEPHCORE_BUZZER_ON: return ZEPHCORE_BUZZER_VIBRATE; + case ZEPHCORE_BUZZER_VIBRATE: return ZEPHCORE_BUZZER_OFF; + case ZEPHCORE_BUZZER_OFF: return ZEPHCORE_BUZZER_SOUND; + default: return ZEPHCORE_BUZZER_ON; + } +} + +bool zephcore_buzzer_mode_audible(uint8_t mode) +{ + return mode == ZEPHCORE_BUZZER_ON || mode == ZEPHCORE_BUZZER_SOUND; +} + +const char *zephcore_buzzer_mode_name(uint8_t mode) +{ + switch (mode) { + case ZEPHCORE_BUZZER_ON: return "sound+vib"; + case ZEPHCORE_BUZZER_VIBRATE: return "vibrate"; + case ZEPHCORE_BUZZER_SOUND: return "sound"; + default: return "silent"; + } +} + +/* Overridden in helpers/ui/buzzer.c on boards that have a buzzer. */ +__weak void zephcore_buzzer_apply(uint8_t mode, bool deferred) +{ + ARG_UNUSED(mode); + ARG_UNUSED(deferred); +} + +__weak bool zephcore_buzzer_has_vibrate(void) +{ + return false; +} diff --git a/zephcore/helpers/buzzer_gate.h b/zephcore/helpers/buzzer_gate.h new file mode 100644 index 0000000..35f3e5e --- /dev/null +++ b/zephcore/helpers/buzzer_gate.h @@ -0,0 +1,67 @@ +/* + * ZephCore - Notification mode (buzzer + vibration) + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: MIT + * + * Always linked, like led_gate — the CLI needs these symbols on boards that + * compile no buzzer at all. + */ + +#ifndef ZEPHCORE_BUZZER_GATE_H +#define ZEPHCORE_BUZZER_GATE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* User-facing mode, as accepted by "set buzzer". */ +#define ZEPHCORE_BUZZER_OFF 0 /* silent */ +#define ZEPHCORE_BUZZER_ON 1 /* sound + vibration */ +#define ZEPHCORE_BUZZER_VIBRATE 2 /* vibration only */ +#define ZEPHCORE_BUZZER_SOUND 3 /* sound only */ +#define ZEPHCORE_BUZZER_MODE_MAX 3 + +/* + * NodePrefs still stores the original buzzer_quiet byte: 0 = not quiet, + * 1 = quiet. Vibration-only and sound-only are the new values 2 and 3, which + * older firmware reads as "quiet" — so a downgrade silences a node left on + * sound-only, which is the safe direction. Conversion lives here so the two + * numberings never leak into the rest of the tree. + */ +uint8_t zephcore_buzzer_mode_from_prefs(uint8_t prefs_byte); +uint8_t zephcore_buzzer_prefs_from_mode(uint8_t mode); + +/* ON -> VIBRATE -> OFF -> SOUND -> ON. With no motor fitted only ON and OFF + * are reachable — the other two would be indistinguishable from them. */ +uint8_t zephcore_buzzer_next_mode(uint8_t mode); + +/** @return true if the mode lets the buzzer make noise */ +bool zephcore_buzzer_mode_audible(uint8_t mode); + +/** @return short label for a mode, for the CLI and the display page */ +const char *zephcore_buzzer_mode_name(uint8_t mode); + +/** @return the mode currently in effect */ +uint8_t zephcore_buzzer_mode(void); + +/** + * Record a mode and apply it to the hardware. + * @param deferred when muting, let an in-flight melody finish first + */ +void zephcore_buzzer_set_mode(uint8_t mode, bool deferred); + +/* Hardware half of zephcore_buzzer_set_mode(). Weak no-op with no buzzer; + * overridden in helpers/ui/buzzer.c. Call the setter, not this. */ +void zephcore_buzzer_apply(uint8_t mode, bool deferred); + +/** @return true if the board has a vibration motor. Weak false. */ +bool zephcore_buzzer_has_vibrate(void); + +#ifdef __cplusplus +} +#endif + +#endif /* ZEPHCORE_BUZZER_GATE_H */ diff --git a/zephcore/helpers/ui-button/ui_pages.c b/zephcore/helpers/ui-button/ui_pages.c index 82f3e44..5356a46 100644 --- a/zephcore/helpers/ui-button/ui_pages.c +++ b/zephcore/helpers/ui-button/ui_pages.c @@ -17,6 +17,7 @@ #include "ui_pages.h" #include "ui_task.h" #include "display.h" +#include #include #include @@ -1266,13 +1267,15 @@ static void render_buzzer(void) char buf[24]; int y = CONTENT_Y; - snprintf(buf, sizeof(buf), "Buzzer: %s", - state.buzzer_quiet ? "off" : "on"); + uint8_t next = zephcore_buzzer_next_mode(state.buzzer_mode); + + snprintf(buf, sizeof(buf), "Alert: %s", + zephcore_buzzer_mode_name(state.buzzer_mode)); mc_display_text(0, y, buf, false); y += LINE_H; - draw_centered(y + 8, - state.buzzer_quiet ? "Press to Enable" : "Press to Disable"); + snprintf(buf, sizeof(buf), "Press for %s", zephcore_buzzer_mode_name(next)); + draw_centered(y + 8, buf); } static void render_leds_mono(void) diff --git a/zephcore/helpers/ui-button/ui_pages.h b/zephcore/helpers/ui-button/ui_pages.h index a41f724..1e691d3 100644 --- a/zephcore/helpers/ui-button/ui_pages.h +++ b/zephcore/helpers/ui-button/ui_pages.h @@ -91,7 +91,7 @@ struct ui_state { uint32_t gps_next_search_s; /* seconds until next search (0=now/off) */ /* Buzzer page */ - bool buzzer_quiet; /* true = muted */ + uint8_t buzzer_mode; /* ZEPHCORE_BUZZER_{OFF,ON,VIBRATE} */ /* LEDs page */ bool leds_disabled; /* true = LEDs off */ diff --git a/zephcore/helpers/ui-button/ui_task.c b/zephcore/helpers/ui-button/ui_task.c index f4d46cc..19e3e25 100644 --- a/zephcore/helpers/ui-button/ui_task.c +++ b/zephcore/helpers/ui-button/ui_task.c @@ -25,6 +25,7 @@ #include "ui_task.h" #include "ui_pages.h" +#include #include #ifdef CONFIG_ZEPHCORE_UI_BUZZER @@ -408,26 +409,28 @@ static void action_flood_advert(void) schedule_render(); } +/* Cycles sound -> vibrate -> silent -> sound. Boards with no motor skip the + * middle step, so they keep the plain on/off toggle they always had. */ static void action_buzzer_toggle(void) { #ifdef CONFIG_ZEPHCORE_UI_BUZZER - bool was_quiet = buzzer_is_quiet(); + uint8_t mode = zephcore_buzzer_next_mode(get_state()->buzzer_mode); - if (was_quiet) { - /* Unmuting: enable first, then play ascending confirmation */ - buzzer_set_quiet(false); + if (zephcore_buzzer_mode_audible(mode)) { + /* Enable first, then play the ascending confirmation */ + zephcore_buzzer_set_mode(mode, false); buzzer_play(MELODY_BUZZER_ON); } else { - /* Muting: play descending confirmation while still enabled. - * Use deferred mute so the "off" melody plays out fully - * before the quiet flag suppresses future sounds. */ + /* Play the descending confirmation while still audible — the + * deferred mute lets it finish before sounds are suppressed. */ buzzer_play(MELODY_BUZZER_OFF); - buzzer_set_quiet_deferred(true); + zephcore_buzzer_set_mode(mode, true); } - /* Persist mute state across reboots */ - mesh_set_buzzer_quiet(!was_quiet); - get_state()->buzzer_quiet = !was_quiet; - LOG_INF("buzzer %s", buzzer_is_quiet() ? "muted" : "unmuted"); + + /* Persist across reboots */ + mesh_set_buzzer_mode(mode); + get_state()->buzzer_mode = mode; + LOG_INF("buzzer mode=%u", mode); #endif schedule_render(); } @@ -1088,11 +1091,11 @@ void ui_set_ble_enabled(bool enabled) s->ble_enabled = enabled; } -void ui_set_buzzer_quiet(bool quiet) +void ui_set_buzzer_mode(uint8_t mode) { struct ui_state *s = get_state(); - s->buzzer_quiet = quiet; + s->buzzer_mode = mode; } void ui_set_offgrid_mode(bool enabled) diff --git a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp index b78b622..c8532ef 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp +++ b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp @@ -275,6 +275,6 @@ extern "C" void ui_set_sensor_data(int16_t, uint32_t, uint16_t, uint16_t) {} extern "C" void ui_set_gps_available(bool) {} extern "C" void ui_set_gps_enabled(bool) {} extern "C" void ui_set_gps_state(uint8_t, uint32_t, uint32_t) {} -extern "C" void ui_set_buzzer_quiet(bool) {} +extern "C" void ui_set_buzzer_mode(uint8_t) {} extern "C" void ui_set_offgrid_mode(bool) {} extern "C" void ui_set_msg_count(uint16_t count) {} diff --git a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp index 4613375..61a32d3 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp +++ b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp @@ -11,6 +11,7 @@ #include "joystick_defs.h" #include "joystick_ui_hooks.h" #include +#include #include #include #include @@ -958,15 +959,16 @@ bool JoystickUITask::isBuzzerQuiet() const void JoystickUITask::toggleBuzzer() { #ifdef CONFIG_ZEPHCORE_UI_BUZZER - bool was_quiet = buzzer_is_quiet(); - if (was_quiet) { - buzzer_set_quiet(false); + uint8_t mode = zephcore_buzzer_next_mode(zephcore_buzzer_mode()); + + if (zephcore_buzzer_mode_audible(mode)) { + zephcore_buzzer_set_mode(mode, false); buzzer_play("bon:d=16,o=7,b=200:c,p,c,p,c,p,p,8e"); } else { buzzer_play("bof:d=16,o=7,b=200:c,p,c,p,c,p,p,8g5"); - buzzer_set_quiet_deferred(true); + zephcore_buzzer_set_mode(mode, true); } - mesh_set_buzzer_quiet(!was_quiet); + mesh_set_buzzer_mode(mode); #endif } diff --git a/zephcore/helpers/ui/buzzer.c b/zephcore/helpers/ui/buzzer.c index 38dfd5d..74ff11f 100644 --- a/zephcore/helpers/ui/buzzer.c +++ b/zephcore/helpers/ui/buzzer.c @@ -16,6 +16,8 @@ */ #include "buzzer.h" +#include "haptic.h" +#include "buzzer_gate.h" #include #include @@ -364,6 +366,8 @@ static void note_work_handler(struct k_work *work) int buzzer_init(void) { + haptic_init(); + /* Check for buzzer alias in devicetree */ const struct device *pwm_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(pwm0)); @@ -420,6 +424,10 @@ int buzzer_init(void) void buzzer_play(const char *rtttl) { + if (rtttl && *rtttl) { + haptic_pulse(); + } + if (!ctx.initialized) { return; } @@ -492,6 +500,27 @@ void buzzer_set_quiet_deferred(bool quiet) LOG_INF("buzzer %s (deferred)", quiet ? "muted" : "enabled"); } +/* Strong overrides of the weak stubs in helpers/buzzer_gate.c */ + +void zephcore_buzzer_apply(uint8_t mode, bool deferred) +{ + bool quiet = !zephcore_buzzer_mode_audible(mode); + + if (quiet && deferred) { + buzzer_set_quiet_deferred(true); + } else { + buzzer_set_quiet(quiet); + } + + haptic_set_enabled(mode == ZEPHCORE_BUZZER_ON || + mode == ZEPHCORE_BUZZER_VIBRATE); +} + +bool zephcore_buzzer_has_vibrate(void) +{ + return haptic_available(); +} + bool buzzer_is_quiet(void) { return ctx.quiet; diff --git a/zephcore/helpers/ui/haptic.c b/zephcore/helpers/ui/haptic.c new file mode 100644 index 0000000..627b098 --- /dev/null +++ b/zephcore/helpers/ui/haptic.c @@ -0,0 +1,86 @@ +/* + * ZephCore - Haptic feedback (DRV2605) + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: MIT + */ + +#include "haptic.h" + +#include +#include +#include + +LOG_MODULE_REGISTER(zephcore_haptic, CONFIG_ZEPHCORE_SENSORS_LOG_LEVEL); + +#if DT_HAS_COMPAT_STATUS_OKAY(ti_drv2605) + +#include +#include + +#define HAPTIC_NODE DT_COMPAT_GET_ANY_STATUS_OKAY(ti_drv2605) + +static const struct device *haptic_dev; +static bool haptic_enabled = true; + +/* Library effect 1 = "Strong Click - 100%", short enough to sit under a + * notification melody without outlasting it. */ +static struct drv2605_rom_data rom_data = { + .trigger = DRV2605_MODE_INTERNAL_TRIGGER, + .library = DRV2605_LIBRARY_LRA, + .seq_regs = { 1 }, +}; + +int haptic_init(void) +{ + const union drv2605_config_data cfg = { .rom_data = &rom_data }; + int rc; + + haptic_dev = DEVICE_DT_GET(HAPTIC_NODE); + if (!device_is_ready(haptic_dev)) { + LOG_INF("no haptic driver found"); + haptic_dev = NULL; + return -ENODEV; + } + + rc = drv2605_haptic_config(haptic_dev, DRV2605_HAPTICS_SOURCE_ROM, &cfg); + if (rc < 0) { + LOG_WRN("haptic config failed: %d", rc); + haptic_dev = NULL; + return rc; + } + + LOG_INF("haptic driver ready"); + return 0; +} + +void haptic_pulse(void) +{ + if (!haptic_dev || !haptic_enabled) { + return; + } + + (void)haptics_start_output(haptic_dev); +} + +void haptic_set_enabled(bool enabled) +{ + haptic_enabled = enabled; + + if (!enabled && haptic_dev) { + (void)haptics_stop_output(haptic_dev); + } +} + +bool haptic_available(void) +{ + return haptic_dev != NULL; +} + +#else /* no ti,drv2605 node — link-compatible stubs */ + +int haptic_init(void) { return -ENODEV; } +void haptic_pulse(void) { } +void haptic_set_enabled(bool enabled) { ARG_UNUSED(enabled); } +bool haptic_available(void) { return false; } + +#endif diff --git a/zephcore/helpers/ui/haptic.h b/zephcore/helpers/ui/haptic.h new file mode 100644 index 0000000..47f8ee4 --- /dev/null +++ b/zephcore/helpers/ui/haptic.h @@ -0,0 +1,38 @@ +/* + * ZephCore - Haptic feedback + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: MIT + * + * Vibration alerts on boards carrying a DRV2605 haptic driver. Every call + * is a no-op on boards without one. + */ + +#ifndef ZEPHCORE_HAPTIC_H +#define ZEPHCORE_HAPTIC_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Initialize the haptic driver from devicetree (ti,drv2605). + * @return 0 on success, -ENODEV if the board has no haptic driver + */ +int haptic_init(void); + +/** Play a short notification buzz. No-op if disabled or uninitialized. */ +void haptic_pulse(void); + +/** Enable or disable vibration. Driven by the notification mode. */ +void haptic_set_enabled(bool enabled); + +/** @return true if the board has a working vibration motor */ +bool haptic_available(void); + +#ifdef __cplusplus +} +#endif + +#endif /* ZEPHCORE_HAPTIC_H */ diff --git a/zephcore/helpers/ui/ui_headless_stubs.c b/zephcore/helpers/ui/ui_headless_stubs.c index b32d052..cae3da5 100644 --- a/zephcore/helpers/ui/ui_headless_stubs.c +++ b/zephcore/helpers/ui/ui_headless_stubs.c @@ -115,7 +115,7 @@ WEAK void ui_set_ble_enabled(bool enabled) ARG_UNUSED(enabled); } -WEAK void ui_set_buzzer_quiet(bool quiet) +WEAK void ui_set_buzzer_mode(uint8_t mode) { ARG_UNUSED(quiet); } diff --git a/zephcore/helpers/ui/ui_mesh_actions.cpp b/zephcore/helpers/ui/ui_mesh_actions.cpp index 90c5103..afd1aa6 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.cpp +++ b/zephcore/helpers/ui/ui_mesh_actions.cpp @@ -11,6 +11,8 @@ */ #include + +#include #include #include #include @@ -61,7 +63,7 @@ static atomic_t pending_ui_actions; * Written before atomic_or on pending_ui_actions, read after atomic_clear, * so the atomic provides ordering. Using atomic_t for portability. */ static atomic_t pending_gps_enabled; -static atomic_t pending_buzzer_quiet; +static atomic_t pending_buzzer_mode; static atomic_t pending_offgrid_enabled; static atomic_t pending_leds_disabled; static atomic_t pending_ble_disabled; @@ -164,10 +166,10 @@ extern "C" void mesh_save_gps_duty_sec(uint32_t sec) k_event_post(s_mesh_events, s_mesh_event_ui_action); } -extern "C" void mesh_set_buzzer_quiet(bool quiet) +extern "C" void mesh_set_buzzer_mode(uint8_t mode) { /* Defer the flash write (savePrefs) to mesh thread */ - atomic_set(&pending_buzzer_quiet, quiet ? 1 : 0); + atomic_set(&pending_buzzer_mode, (atomic_val_t)mode); atomic_or(&pending_ui_actions, UI_ACTION_BUZZER_TOGGLE); k_event_post(s_mesh_events, s_mesh_event_ui_action); } @@ -248,9 +250,9 @@ extern "C" void mesh_handle_ui_actions(void) } if (actions & UI_ACTION_BUZZER_TOGGLE) { - bool bq = atomic_get(&pending_buzzer_quiet) != 0; - s_mesh->prefs.buzzer_quiet = bq ? 1 : 0; - LOG_INF("buzzer_quiet=%d (button)", bq); + uint8_t mode = (uint8_t)atomic_get(&pending_buzzer_mode); + s_mesh->prefs.buzzer_quiet = zephcore_buzzer_prefs_from_mode(mode); + LOG_INF("buzzer mode=%u (button)", mode); need_save = true; } diff --git a/zephcore/helpers/ui/ui_mesh_actions.h b/zephcore/helpers/ui/ui_mesh_actions.h index 922f30e..da4d196 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.h +++ b/zephcore/helpers/ui/ui_mesh_actions.h @@ -55,7 +55,7 @@ void mesh_send_flood_advert(void); void mesh_send_zerohop_advert(void); void mesh_gps_set_enabled(bool enable); void mesh_ble_set_enabled(bool enable); -void mesh_set_buzzer_quiet(bool quiet); +void mesh_set_buzzer_mode(uint8_t mode); void mesh_set_offgrid_mode(bool enable); void mesh_set_leds_disabled(bool disabled); void mesh_disable_power_regulators(void); diff --git a/zephcore/helpers/ui/ui_mesh_actions_stubs.c b/zephcore/helpers/ui/ui_mesh_actions_stubs.c index b5832ee..4f8e0bd 100644 --- a/zephcore/helpers/ui/ui_mesh_actions_stubs.c +++ b/zephcore/helpers/ui/ui_mesh_actions_stubs.c @@ -17,7 +17,7 @@ __attribute__((weak)) void mesh_send_flood_advert(void) {} __attribute__((weak)) void mesh_send_zerohop_advert(void) {} __attribute__((weak)) void mesh_gps_set_enabled(bool enable) { ARG_UNUSED(enable); } __attribute__((weak)) void mesh_ble_set_enabled(bool enable) { ARG_UNUSED(enable); } -__attribute__((weak)) void mesh_set_buzzer_quiet(bool quiet) { ARG_UNUSED(quiet); } +__attribute__((weak)) void mesh_set_buzzer_mode(uint8_t mode) { ARG_UNUSED(mode); } __attribute__((weak)) void mesh_set_offgrid_mode(bool enable) { ARG_UNUSED(enable); } __attribute__((weak)) void mesh_set_leds_disabled(bool disabled) { ARG_UNUSED(disabled); } __attribute__((weak)) void mesh_disable_power_regulators(void) {} diff --git a/zephcore/helpers/ui/ui_task.h b/zephcore/helpers/ui/ui_task.h index 0401bb1..8556813 100644 --- a/zephcore/helpers/ui/ui_task.h +++ b/zephcore/helpers/ui/ui_task.h @@ -163,9 +163,9 @@ void ui_set_gps_state(uint8_t state, uint32_t last_fix_age_s, uint32_t next_sear void ui_set_ble_enabled(bool enabled); /** - * Set buzzer quiet state (for display page). + * Set notification mode (for display page). */ -void ui_set_buzzer_quiet(bool quiet); +void ui_set_buzzer_mode(uint8_t mode); /** * Set LEDs disabled state (for display page). diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c index 477589c..de2638e 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c +++ b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c @@ -46,6 +46,8 @@ struct lr20xx_config { uint16_t tcxo_voltage_mv; uint32_t tcxo_startup_delay_ms; bool rx_boosted; + /* Chip-side DIO wired to the MCU IRQ line (5..11) */ + uint8_t irq_dio; /* RF switch DIO bitmasks (bit 0 = DIO5, bit 1 = DIO6, ...) */ uint8_t rfswitch_enable; uint8_t rfswitch_standby; @@ -194,6 +196,22 @@ static float bw_enum_to_khz(enum lora_signal_bandwidth bw) } } +/* ── IRQ DIO ────────────────────────────────────────────────────────── */ + +static inline lr20xx_system_dio_t lr20xx_irq_dio(const struct lr20xx_config *cfg) +{ + return (lr20xx_system_dio_t)cfg->irq_dio; +} + +/* DIO5 can only be pulled up; every other DIO gets a pull-down. */ +static inline lr20xx_system_dio_drive_t +lr20xx_irq_dio_pull(const struct lr20xx_config *cfg) +{ + return cfg->irq_dio == LR20XX_SYSTEM_DIO_5 + ? LR20XX_SYSTEM_DIO_DRIVE_PULL_UP + : LR20XX_SYSTEM_DIO_DRIVE_PULL_DOWN; +} + /* ── Configure RF switch DIOs ───────────────────────────────────────── */ static void lr20xx_configure_rfswitch(void *ctx, const struct lr20xx_config *cfg) @@ -341,10 +359,9 @@ static void lr20xx_hardware_reset(struct lr20xx_data *data, lr20xx_configure_rfswitch(ctx, cfg); - /* DIO9 is the physical IRQ line (pin 15 on NiceRF module → MCU P0.10) */ - lr20xx_system_set_dio_function(ctx, LR20XX_SYSTEM_DIO_9, + lr20xx_system_set_dio_function(ctx, lr20xx_irq_dio(cfg), LR20XX_SYSTEM_DIO_FUNC_IRQ, - LR20XX_SYSTEM_DIO_DRIVE_NONE); + lr20xx_irq_dio_pull(cfg)); lr20xx_radio_common_set_rx_tx_fallback_mode(ctx, LR20XX_RADIO_FALLBACK_STDBY_RC); @@ -417,16 +434,19 @@ static void lr20xx_apply_modem_config(struct lr20xx_data *data, : LR20XX_RADIO_COMMON_RX_PATH_BOOST_MODE_NONE); data->rx_boost_applied = data->rx_boost_enabled; - /* LR20xx uses PPM offset instead of explicit LDRO. - * PPM_1_4 (1 bin every 4) is equivalent to LDRO for high-SF - * wide-time-on-air configurations. Use recommended value. */ + /* PPM offset is this chip's name for LDRO and occupies the same wire + * field. The Semtech "recommended offset" helper only ever enables it + * for SF11/SF12, which disagrees with the rest of the mesh below + * 125 kHz — every other stack switches on symbol time > 16.38 ms. */ + uint32_t bw_hz = (uint32_t)(bw_enum_to_khz(mc->bandwidth) * 1000.0f); + uint32_t symbol_time_us = ((1U << (uint8_t)mc->datarate) * 1000000U) / bw_hz; + lr20xx_radio_lora_mod_params_t mod = { .sf = (lr20xx_radio_lora_sf_t)mc->datarate, .bw = bw_enum_to_lr20xx(mc->bandwidth), .cr = cr_enum_to_lr20xx(mc->coding_rate), - .ppm = lr20xx_radio_lora_get_recommended_ppm_offset( - (lr20xx_radio_lora_sf_t)mc->datarate, - bw_enum_to_lr20xx(mc->bandwidth)), + .ppm = (symbol_time_us > 16380) ? LR20XX_RADIO_LORA_PPM_1_4 + : LR20XX_RADIO_LORA_NO_PPM, }; rc = lr20xx_radio_lora_set_modulation_params(ctx, &mod); LOG_DBG("modem_cfg: set_mod(SF%d BW%d CR%d PPM%d)=%d", @@ -459,11 +479,7 @@ static void lr20xx_apply_modem_config(struct lr20xx_data *data, lr20xx_radio_common_pa_cfg_t pa; int8_t pa_val; - /* DEBUG/TEST: force MINIMUM power (-9dBm, 1 PA slice) to probe the - * supply-sag hypothesis. If TX keys here (mode=5 + carrier) but - * not at +22dBm (7 slices), the rail can't source PA current → - * hardware power limit. REVERT to mc->tx_power after testing. */ - lr20xx_get_pa_cfg_for_power(-9, &pa, &pa_val); + lr20xx_get_pa_cfg_for_power(mc->tx_power, &pa, &pa_val); rc = lr20xx_radio_common_set_pa_cfg(ctx, &pa); LOG_DBG("modem_cfg: set_pa_cfg(sel=%d mode=%d duty=%d slices=%d hf_duty=%d)=%d", pa.pa_sel, pa.pa_lf_mode, pa.pa_lf_duty_cycle, @@ -475,7 +491,7 @@ static void lr20xx_apply_modem_config(struct lr20xx_data *data, pa_val, rc); } - rc = lr20xx_system_set_dio_irq_cfg(ctx, LR20XX_SYSTEM_DIO_9, + rc = lr20xx_system_set_dio_irq_cfg(ctx, lr20xx_irq_dio(cfg), LR20XX_SYSTEM_IRQ_ALL_MASK & ~(LR20XX_SYSTEM_IRQ_FIFO_RX | LR20XX_SYSTEM_IRQ_FIFO_TX)); LOG_DBG("modem_cfg: set_dio_irq=%d", rc); @@ -952,28 +968,9 @@ static int lr20xx_lora_send_async(const struct device *dev, lr20xx_radio_common_set_tx(ctx, 5000); -#if IS_ENABLED(CONFIG_LOG) - /* DEBUG: poll chip state right after SET_TX. Non-destructive - * (get_status does NOT clear IRQs). We want to see: - * - cmd= on the FIRST poll == SET_TX's own command status - * (2=OK accepted, 1=PERR rejected, 0=FAIL not executed) - * - mode transitions 1(STBY)->5(TX)->1(fallback) if it really TXes - * - irq gaining TX_DONE (bit19, 0x00080000) at the chip level - * - whether DIO9 physically asserts (the MCU IRQ line) - * Diagnostic only — the spi_mutex is held, so the DIO9 work handler - * blocks until we unlock, then processes TX_DONE normally. */ - for (int i = 0; i < 20; i++) { - lr20xx_system_stat1_t s1 = {0}; - lr20xx_system_stat2_t s2 = {0}; - lr20xx_system_irq_mask_t dbgirq = 0; - lr20xx_system_get_status(ctx, &s1, &s2, &dbgirq); - LOG_INF("TXpoll[%2d] cmd=%d mode=%d irq=0x%08x BUSY=%d DIO9=%d", - i, s1.command_status, s2.chip_mode, dbgirq, - gpio_pin_get_dt(&data->hal_ctx.busy), - gpio_pin_get_dt(&data->hal_ctx.dio1)); - k_msleep(20); - } -#endif + /* Command status here is SET_TX's own (2=accepted, 1=rejected, + * 0=not executed) and the mode should have left standby. */ + DUMP_CHIP_STATE(ctx, &data->hal_ctx, "post-SET_TX"); k_mutex_unlock(&data->spi_mutex); @@ -1447,26 +1444,12 @@ static int lr20xx_hw_init(struct lr20xx_data *data, LOG_INF("LR20xx SDK get_version: major=%u minor=%u", ver.major, ver.minor); - /* CRITICAL: Read raw 4 bytes from GET_VERSION (opcode 0x0101) to - * determine if this is LR11x0 or LR20xx silicon. - * LR11x0 returns: [hw_type, device_use, fw_major, fw_minor] (4 bytes) - * LR20xx returns: [major, minor] (2 bytes, extra bytes would be 0x00) - * If byte[0]=0x01/0x02/0x03, it's LR1110/LR1120/LR1121 (LR11x0!) */ - { - const uint8_t cmd[2] = { 0x01, 0x01 }; - uint8_t raw[4] = { 0 }; - lr20xx_hal_read(ctx, cmd, 2, raw, 4); - LOG_DBG("GET_VERSION raw bytes: 0x%02x 0x%02x 0x%02x 0x%02x", - raw[0], raw[1], raw[2], raw[3]); - if (raw[0] == 0x01) { - LOG_WRN("*** CHIP IDENTIFIES AS LR1110 (LR11x0 family!) ***"); - } else if (raw[0] == 0x02) { - LOG_WRN("*** CHIP IDENTIFIES AS LR1120 (LR11x0 family!) ***"); - } else if (raw[0] == 0x03) { - LOG_WRN("*** CHIP IDENTIFIES AS LR1121 (LR11x0 family!) ***"); - } else { - LOG_DBG("Chip type byte=0x%02x (LR20xx if not 0x01-0x03)", raw[0]); - } + /* The only base FW version the datasheet documents is 1.24 (0x01/0x18). + * Anything else still runs — the mismatch is worth a line in the log + * when a bring-up goes sideways, not a hard failure. */ + if (ver.major != 0x01 || ver.minor != 0x18) { + LOG_WRN("Unexpected LR2021 FW %u.%u (datasheet documents 1.24)", + ver.major, ver.minor); } DUMP_CHIP_STATE(ctx, &data->hal_ctx, "post-reset"); @@ -1497,22 +1480,13 @@ static int lr20xx_hw_init(struct lr20xx_data *data, cfg->rfswitch_enable, cfg->rfswitch_standby, cfg->rfswitch_rx, cfg->rfswitch_tx, cfg->rfswitch_tx_hp); - st = lr20xx_system_set_dio_function(ctx, LR20XX_SYSTEM_DIO_9, + st = lr20xx_system_set_dio_function(ctx, lr20xx_irq_dio(cfg), LR20XX_SYSTEM_DIO_FUNC_IRQ, - LR20XX_SYSTEM_DIO_DRIVE_NONE); + lr20xx_irq_dio_pull(cfg)); st = lr20xx_radio_common_set_rx_tx_fallback_mode(ctx, LR20XX_RADIO_FALLBACK_STDBY_RC); - /* DEBUG/TEST: disable the low-battery / EoL detector. The chip was - * raising LOW_BATTERY (IRQ bit10, 0x400) during FE cal and TX and - * refusing to enter TX. Disabling it disambiguates: - * - TX now keys → it was a FALSE VBAT reading (sense pin), done. - * - TX still dead → genuine supply brownout under RF (hardware). - * Default trim is 1.88V; we both disable AND set the lowest (1.60V). */ - st = lr20xx_system_set_lbd_cfg(ctx, false, LR20XX_SYSTEM_LBD_TRIM_1_60_V); - LOG_DBG("init: disable LBD (low-battery detect)=%d", st); - DUMP_CHIP_STATE(ctx, &data->hal_ctx, "pre-cal"); lr20xx_system_clear_errors(ctx); @@ -1661,6 +1635,7 @@ static DEVICE_API(lora, lr20xx_lora_api) = { .tcxo_startup_delay_ms = \ DT_INST_PROP_OR(n, tcxo_startup_delay_ms, 5), \ .rx_boosted = DT_INST_PROP(n, rx_boosted), \ + .irq_dio = DT_INST_PROP_OR(n, irq_dio, 9), \ .rfswitch_enable = DT_INST_PROP_OR(n, rfswitch_enable, 0), \ .rfswitch_standby = DT_INST_PROP_OR(n, rfswitch_standby, 0),\ .rfswitch_rx = DT_INST_PROP_OR(n, rfswitch_rx, 0), \ diff --git a/zephcore/patches/zephyr-new/dts/bindings/lora/semtech,lr2021.yaml b/zephcore/patches/zephyr-new/dts/bindings/lora/semtech,lr2021.yaml index c5ee42b..a3b8105 100644 --- a/zephcore/patches/zephyr-new/dts/bindings/lora/semtech,lr2021.yaml +++ b/zephcore/patches/zephyr-new/dts/bindings/lora/semtech,lr2021.yaml @@ -42,6 +42,13 @@ properties: type: boolean description: Enable RX boosted mode for better sensitivity (+~3dB, +~2mA). + irq-dio: + type: int + default: 9 + description: | + Chip-side DIO wired to the MCU interrupt line, 5..11. This is the DIO + number on the LR2021 itself, not the SoC pin — the SoC side is dio1-gpios. + rfswitch-enable: type: int default: 0 diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index d53b61e..9b6a587 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -31,6 +31,7 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include "ui_mesh_actions.h" #include "oled_power.h" #include "led_gate.h" +#include "buzzer_gate.h" #if IS_ENABLED(CONFIG_ZEPHCORE_UI_BUZZER) #include "buzzer.h" #endif @@ -1506,15 +1507,16 @@ int main(void) LOG_INF("offgrid mode: %s (from prefs)", companion_mesh.prefs.client_repeat ? "on" : "off"); - /* Restore buzzer mute state from persisted prefs, then play - * startup chime only if buzzer is not muted. - * buzzer_init() defaults to quiet=true, so we must always - * call buzzer_set_quiet() to apply the saved preference. */ + /* Restore the notification mode from persisted prefs, then play the + * startup chime only if sound is on. buzzer_init() defaults to + * quiet=true, so the saved preference must always be applied. */ #if IS_ENABLED(CONFIG_ZEPHCORE_UI_BUZZER) - buzzer_set_quiet(companion_mesh.prefs.buzzer_quiet); - ui_set_buzzer_quiet(companion_mesh.prefs.buzzer_quiet); - LOG_INF("buzzer: quiet=%s (from prefs)", - companion_mesh.prefs.buzzer_quiet ? "true" : "false"); + { + uint8_t bmode = zephcore_buzzer_mode_from_prefs(companion_mesh.prefs.buzzer_quiet); + zephcore_buzzer_set_mode(bmode, false); + ui_set_buzzer_mode(bmode); + LOG_INF("buzzer: mode=%u (from prefs)", bmode); + } ui_play_startup_chime(); #endif diff --git a/zephcore/src/main_repeater.cpp b/zephcore/src/main_repeater.cpp index 6c6ffbc..c8b84ea 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -422,6 +422,10 @@ static uint16_t get_battery_mv(void) /* LR1110 via Zephyr LoRa driver */ static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); static mesh::LR1110Radio lora_radio(lora_dev, zephyr_board); +#elif IS_ENABLED(CONFIG_ZEPHCORE_RADIO_LR2021) +/* LR2021 via Zephyr LoRa driver */ +static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); +static mesh::LR2021Radio lora_radio(lora_dev, zephyr_board); #elif IS_ENABLED(CONFIG_ZEPHCORE_RADIO_SX127X) /* SX127x via Zephyr loramac-node driver */ static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); diff --git a/zephcore/src/main_room_server.cpp b/zephcore/src/main_room_server.cpp index c1ff980..09431a1 100644 --- a/zephcore/src/main_room_server.cpp +++ b/zephcore/src/main_room_server.cpp @@ -356,6 +356,10 @@ static uint16_t get_battery_mv(void) /* LR1110 via Zephyr LoRa driver */ static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); static mesh::LR1110Radio lora_radio(lora_dev, zephyr_board); +#elif IS_ENABLED(CONFIG_ZEPHCORE_RADIO_LR2021) +/* LR2021 via Zephyr LoRa driver */ +static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); +static mesh::LR2021Radio lora_radio(lora_dev, zephyr_board); #elif IS_ENABLED(CONFIG_ZEPHCORE_RADIO_SX127X) /* SX127x via Zephyr loramac-node driver */ static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0));