M9 SD card settle

This commit is contained in:
liquidraver committed 2026-07-21 15:29:05 +02:00
1 parent b2b7d0dc84
commit 47f24a0b1c
6 files changed
+178 -28

No files matched your search

@@ -117,8 +117,12 @@ the screen is mirrored or upside down, that's a known one-line fix), GPS output
battery calibration, and whether the USB-C port reaches the native USB pads at all (BLE is the companion
path meanwhile). Not ported: the I2C keyboard, which is driven by a second MCU.
Also worth knowing on this board specifically: the **SD slot shares the radio's SPI bus**. That has no
effect on normal operation, but it does matter if you use the LR1110 updater below.
Also worth knowing on this board specifically: the **SD slot shares the radio's SPI bus** with the LR1110
and the display. An inserted card was confirmed to break LR1110 radio-firmware flashing on this board — the
write reports success and the radio then runs nothing. To keep a card from disturbing normal operation, the
firmware now puts any inserted card into SPI idle at startup, before the radio driver initialises, so it
stays quiet until something actually addresses it. **If the M9 misbehaves in a way you can't explain, try
it once with the card removed** — that is still the fastest way to rule the slot out.
Feedback from anyone with an M9 is genuinely useful here — the bring-up list in the board's README says
exactly what to look at.
+13
View File
@@ -530,6 +530,19 @@ else()
message(STATUS "ZephCore Battery: generic LiPo curve")
endif()
# ========== Board-specific startup quirks ==========
# Optional per-board sd_quiesce.c — boards whose SD slot shares the radio's SPI
# bus put any inserted card into SPI idle before the radio driver initialises,
# so it cannot disturb the bus. Registers itself with SYS_INIT; nothing calls
# into it, so it is simply compiled in when the board provides one.
file(GLOB_RECURSE _BOARD_SD_QUIESCE
"${CMAKE_CURRENT_SOURCE_DIR}/boards/*/${BOARD_BASE}/sd_quiesce.c")
if(_BOARD_SD_QUIESCE)
list(GET _BOARD_SD_QUIESCE 0 _BOARD_SD_QUIESCE)
message(STATUS "ZephCore SD: shared-bus quiesce (${_BOARD_SD_QUIESCE})")
target_sources(app PRIVATE "${_BOARD_SD_QUIESCE}")
endif()
# Native-Linux (native_sim) runtime setup: force real-time clock mode so the
# simulated clock tracks wall time (required for the real SX126x radio's
# BUSY/DIO1 timing). Bakes in the equivalent of the --rt command-line flag.
@@ -0,0 +1,137 @@
/*
* ThinkNode M9 — quiesce an inserted SD card before the radio comes up.
* SPDX-License-Identifier: MIT
*
* The SD slot shares SPI2 with the LR1110 and the ST7789. Its CS is parked
* HIGH by the `sd-cs-park` gpio-hog in the base DTS, which is enough for a
* card that is powered and has been initialised: it tri-states its data-out
* while deselected and ignores the bus.
*
* A card that has never been addressed is not in the second of those states: it
* comes up in native SD mode, not SPI mode, so it has not yet been told to
* ignore traffic it was not addressed for. Until something runs the SPI-mode
* entry sequence, its behaviour on a busy shared bus is simply not defined by
* the spec.
*
* The card is powered normally whenever the firmware runs, so this is NOT the
* parasitic-supply trap that VEXT once was for the LR1110. Confirmed on the
* V1.0 schematic: SD_3V3 comes off VDD_PERIPH through series R86, with no
* enable of its own, and VDD_PERIPH is the MOSFET-gated rail that GPIO18
* (VDD_PERIPH_EN) switches — asserted at boot by the tft_pwr_enable regulator.
*
* Worth knowing when debugging this board: VDD_PERIPH fans out through one
* series resistor per consumer — R82 to VDD_RADIO, R86 to SD_3V3, R88 to
* DISPLAY_VDD, R85 to RTC_3V3. The radio and the SD slot therefore share a
* single gated rail. If an inserted card ever turns out to disturb the radio
* in a way this quiesce does not fix, supply sag under combined load is the
* next suspect, and no amount of bus discipline will address it.
*
* An inserted card was field-confirmed to break LR1110 flash programming on
* this board: every write reported OK, the chip programmed for real, and the
* image still failed its integrity check at boot with no error reported
* anywhere. This runs the standard SPI-mode entry sequence once at startup so
* any card present lands in SPI idle — a defined, quiet state where it ignores
* everything until selected — rather than whatever it powered up into.
*
* Runs at POST_KERNEL priority 70: after the SPI bus (KERNEL_INIT_PRIORITY_DEVICE
* = 50) and GPIO, before the LoRa driver (CONFIG_LORA_INIT_PRIORITY = 90), so
* the card is quiet before any radio traffic exists.
*
* Best-effort by design. Every failure path leaves CS parked HIGH exactly as
* the hog had it and returns success — a card we could not talk to is no worse
* off than one we never tried, and refusing to boot over it would be far worse
* than the problem.
*/
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/init.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/spi.h>
#include <string.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(m9_sd_quiesce, CONFIG_ZEPHCORE_LOG_LEVEL);
/* SD CS is GPIO48 = gpio1 pin 16. No DT node describes the slot — the base
* board DTS only parks this pin with a gpio-hog. */
#define SD_CS_PORT DT_NODELABEL(gpio1)
#define SD_CS_PIN 16
/* The radio's bus. Taken from the lora node so this cannot drift apart from
* the devicetree if SPI2 is ever renumbered. */
#define SD_SPI_BUS DT_BUS(DT_NODELABEL(lora))
static int m9_sd_quiesce(void)
{
const struct device *spi = DEVICE_DT_GET(SD_SPI_BUS);
const struct device *cs_port = DEVICE_DT_GET(SD_CS_PORT);
/* SD initialisation is specified at 100-400 kHz; the radio keeps its
* own clock, this config is local to these few transfers. */
struct spi_config cfg = {
.frequency = 400000,
.operation = SPI_WORD_SET(8) | SPI_TRANSFER_MSB,
};
/* CMD0 GO_IDLE_STATE, arg 0, CRC7 0x95 — CRC is still checked in the
* card's power-up state, so it must be correct. */
static const uint8_t cmd0[6] = { 0x40, 0x00, 0x00, 0x00, 0x00, 0x95 };
uint8_t tx[8], rx[8];
if (!device_is_ready(spi) || !device_is_ready(cs_port)) {
return 0;
}
if (gpio_pin_configure(cs_port, SD_CS_PIN, GPIO_OUTPUT_HIGH) < 0) {
return 0;
}
/* >=74 clocks with CS HIGH — the SD power-up requirement. */
memset(tx, 0xFF, sizeof(tx));
const struct spi_buf wake_buf = { .buf = tx, .len = sizeof(tx) };
const struct spi_buf_set wake = { .buffers = &wake_buf, .count = 1 };
if (spi_write(spi, &cfg, &wake) < 0 || spi_write(spi, &cfg, &wake) < 0) {
goto park;
}
/* Select, send CMD0, read the reply. Response bytes are clocked with
* MOSI held high as the spec requires — spi_read() would drive zeros. */
gpio_pin_set(cs_port, SD_CS_PIN, 0);
const struct spi_buf cmd_buf = { .buf = (uint8_t *)cmd0, .len = sizeof(cmd0) };
const struct spi_buf_set cmd = { .buffers = &cmd_buf, .count = 1 };
if (spi_write(spi, &cfg, &cmd) == 0) {
memset(tx, 0xFF, sizeof(tx));
const struct spi_buf rtx_buf = { .buf = tx, .len = sizeof(rx) };
const struct spi_buf_set rtx = { .buffers = &rtx_buf, .count = 1 };
const struct spi_buf rrx_buf = { .buf = rx, .len = sizeof(rx) };
const struct spi_buf_set rrx = { .buffers = &rrx_buf, .count = 1 };
if (spi_transceive(spi, &cfg, &rtx, &rrx) == 0) {
for (size_t i = 0; i < sizeof(rx); i++) {
if ((rx[i] & 0x80) == 0) { /* valid R1 token */
LOG_INF("SD card present (R1=0x%02X) — "
"put into SPI idle", rx[i]);
goto release;
}
}
}
}
LOG_DBG("No SD card detected");
release:
/* Deselect, then one more byte so the card releases the bus. */
gpio_pin_set(cs_port, SD_CS_PIN, 1);
memset(tx, 0xFF, sizeof(tx));
const struct spi_buf rel_buf = { .buf = tx, .len = 1 };
const struct spi_buf_set rel = { .buffers = &rel_buf, .count = 1 };
spi_write(spi, &cfg, &rel);
park:
/* Restore the hog's parked state. */
gpio_pin_configure(cs_port, SD_CS_PIN, GPIO_OUTPUT_HIGH);
return 0;
}
SYS_INIT(m9_sd_quiesce, POST_KERNEL, 70);
@@ -4,13 +4,12 @@ A pocket LoRa node built on the **nRF52840 + SX1262** (RAK4630 stamp module on
a custom muzi baseboard), with GNSS, buzzer, battery-backed RTC and a single
user button. No display.
The port is cross-derived from three sources, because none of them agrees with
the others on every pin: Arduino MeshCore's
The port is cross-derived from several sources, because none of them agrees
with the others on every pin: Arduino MeshCore's
[`variants/muziworks_r1_neo`](https://github.com/meshcore-dev/MeshCore/pull/2007),
Meshtastic's
[`variants/nrf52840/r1-neo`](https://github.com/meshtastic/firmware/blob/master/variants/nrf52840/r1-neo/variant.h),
and the vendor's own [product page](https://muzi.works/products/r1-neo-complete-meshtastic-device).
Where they conflict, the reasoning is recorded in the DTS comments and below.
other third-party firmware for the same board, and the vendor's own
documentation. Where they conflict, the reasoning is recorded in the DTS
comments and below.
## Build
@@ -67,10 +66,10 @@ No QSPI external flash — contacts and channels live in internal flash.
- **GPS TX/RX are swapped relative to the net names.** The schematic labels
`UART_GPS_RX` (P0.24) and `UART_GPS_TX` (P0.25) from the *module's* point of
view, so the MCU's receive pin is P0.25. Meshtastic maps them that way;
Arduino MeshCore takes the labels literally and ends up listening on the
module's own input pin. ZephCore follows Meshtastic. Getting this backwards
is silent — the UART simply never sees a byte.
view, so the MCU's receive pin is P0.25. Arduino MeshCore takes the labels
literally and ends up listening on the module's own input pin; ZephCore uses
the corrected mapping. Getting this backwards is silent — the UART simply
never sees a byte.
- **Soft power, not a switch.** The rail is gated by an I/O controller that
only holds the DCDC up while P0.13 is asserted, and P0.29 tells it the MCU
is alive (this also enables button and LED passthrough). Both are `gpio-hog`
@@ -89,8 +88,8 @@ No QSPI external flash — contacts and channels live in internal flash.
## To verify on real hardware
1. **Battery multiplier.** `vbat-mv-multiplier = 6146` is a placeholder. The
two upstreams disagree on the divider: Arduino MeshCore's constant implies
~1.73 (→ 6259, the RAK4631-family value), Meshtastic's variant.h states
sources disagree on the divider: Arduino MeshCore's constant implies
~1.73 (→ 6259, the RAK4631-family value), the vendor documentation states
1.667 (→ 6031). Measure the cell with a DMM, compare against `get batt`,
and set the value with ZephCore's convention:
`multiplier = divider_ratio * 1.005 * 3600`.
@@ -10,11 +10,10 @@
* Direction note: the schematic net names UART_GPS_RX (P0.24) and
* UART_GPS_TX (P0.25) are labelled from the GPS module's point of
* view, so UART_GPS_RX is the module's input and therefore the MCU's
* TX. Meshtastic's r1-neo variant.h maps them that way
* (PIN_SERIAL1_RX 25 / PIN_SERIAL1_TX 24); Arduino MeshCore's variant
* takes the net names literally and has them the other way round,
* which leaves the MCU listening on the module's own input pin — i.e.
* silence, and no fix, ever. Follow Meshtastic here. */
* TX, and UART_GPS_TX is the MCU's RX. Arduino MeshCore's variant takes
* the net names literally and has them the other way round, which
* leaves the MCU listening on the module's own input pin — i.e.
* silence, and no fix, ever. The mapping below is the correct one. */
uart0_default: uart0_default {
group1 {
psels = <NRF_PSEL(UART_TX, 0, 24)>;
@@ -60,9 +60,7 @@
user_button: button_0 {
/* Active HIGH (pressed = VCC), no internal pull — the board
* has an external pull-down and the line is passed through
* from the I/O controller. Matches Meshtastic's
* BUTTON_ACTIVE_LOW 0 / BUTTON_ACTIVE_PULLUP 0 /
* BUTTON_SENSE_TYPE INPUT_SENSE_HIGH. The polarity here is
* from the I/O controller. The polarity here is
* what ui_prepare_for_system_off() reads to pick the
* System OFF SENSE level, so it must stay correct. */
gpios = <&gpio0 26 GPIO_ACTIVE_HIGH>;
@@ -122,10 +120,10 @@
* multiplier = divider_ratio * 1.005 * 3600
* (3.6 V full scale from ADC_GAIN_1_6 + ADC_REF_INTERNAL, +0.5% for
* nRF SAADC gain error). The divider ratio itself is disputed between
* the two upstreams: Arduino MeshCore's constant implies ~1.73
* (→ 6259, the RAK4631-family value), Meshtastic's r1-neo variant.h
* states 1.667 (→ 6031). 6146 splits them and is within ~2% of either;
* correct it once the board has been measured. */
* sources: Arduino MeshCore's constant implies ~1.73 (→ 6259, the
* RAK4631-family value), while the vendor documentation states 1.667
* (→ 6031). 6146 splits them and is within ~2% of either; correct it
* once the board has been measured. */
zephyr,user {
io-channels = <&adc 7>;
vbat-mv-multiplier = <6146>;
@@ -292,8 +290,8 @@
reg = <0>;
spi-max-frequency = <8000000>;
/* LoRa control pins. reset-gpios is wired and used — Meshtastic
* drives P1.06 as SX126X_RESET; Arduino MeshCore's RADIOLIB_NC is
/* LoRa control pins. reset-gpios is wired and used — P1.06 is
* the SX126x reset line; Arduino MeshCore's RADIOLIB_NC is
* the outlier. ZephCore needs it: ui_prepare_for_system_off()
* parks the radio in HW reset before System OFF. */
reset-gpios = <&gpio1 6 GPIO_ACTIVE_LOW>;