commit 9825b9b4f8145de5db044c968a627e085d6e21c8 Author: SSAnkur Date: Sat Oct 17 20:39:01 2026 +0600 Initial Commit diff --git a/LYWSD03MMC-BTHome.bin b/LYWSD03MMC-BTHome.bin new file mode 100644 index 0000000..762a078 Binary files /dev/null and b/LYWSD03MMC-BTHome.bin differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a4368c --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# LYWSD03MMC 2025 B1.5 (HW B1.1) BTHome v2 beacon + LCD + OTA + +Port of the nRF52840/Zephyr SHT41 beacon to the Xiaomi LYWSD03MMC +(Telink TLSR8251). Same behaviour: + +- every 60 s: measure temperature/humidity, read the battery right after the + sensor load, update the advertisement +- BTHome v2 advertisement every 10 s, public (factory) address, + objects 0x0C voltage (mV), 0x2E humidity (%), 0x45 temperature (0.1 °C) +- name `LYWSD03MMC` in the advertisement and the scan response, so passive + scanners (ESPHome `active: false`) see it too (`ADV_NAME_IN_ADV 0` keeps + it in the scan response only, as pvvx does) +- a failed sensor read keeps advertising the last good T/RH; the voltage is + updated on every measurement +- Telink OTA service for over-the-air firmware updates, reachable only + during the first 2 minutes after the battery is inserted (see Flashing) + +plus the display: temperature (0.1 °C, `°C`), humidity (`%`), battery icon +below 2.4 V, `-- --` until the first good reading. The LCD controller holds +the image itself and is only written when a value changes. + +## Power saving (same methods as pvvx/ATC_MiThermometer) + +- No wake-ups of its own: a measurement starts right after every 6th + advertising event, while the chip is awake anyway. +- The CPU sleeps during the ~12 ms sensor conversion (pvvx + `SENSOR_SLEEP_MEASURE`); an app wake-up timer collects the result. +- VDD is sampled while the sensor converts, i.e. under load. +- Deep-sleep retention between events, also while connected: the device + requests 20 ms interval / latency 49, so it wakes once per second. +- PB0 (battery probe) is driven high permanently; sensor I2C at 700 kHz; + RF power restored after every suspend. +- Battery protection: below 2.2 V at power-on, or below 2.0 V while running, + the LCD shows the voltage and `Lo` and the device deep-sleeps for 3 min + before any flash write or radio use, then retries. + +Outside the 2-minute OTA window the advertising is non-connectable, as in +the original build: the radio does not listen after each packet. + +## Hardware: 2025 "B1.5" only + +This build supports **only the LYWSD03MMC sold since 2025 with "B1.5" +printed on the PCB** (next to the battery holder), whose LCD controller is +driven over bit-banged SPI. pvvx firmware / TelinkMiFlasher report this +board as **HW B1.1**, which is the reliable way to tell it apart. + +| Board | Sensor | LCD controller | +|-------|--------------------|-----------------------------------| +| "B1.5" 2025 (pvvx B1.1) | SHTV3 (SHTC3) 0x70 | SPI, PD7 CLK / PB7 SDI, write-only | + +There is no auto-detection. On any other unit (original UART-LCD B1.5, +B1.4, B1.6, B1.7, B1.9, B2.0) the display stays blank and/or the sensor read +fails: do not flash it there. + +Pinout, segment map and the LCD frame format come from +pvvx/ATC_MiThermometer. + +## Differences from the nRF build + +- The sensor is wired straight to the coin cell, so there is no power gating; + the SHTV3 is put to sleep explicitly after every measurement. +- Voltage is VDD (= CR2032), measured by driving the unbonded PB0 pad high + and sampling it with the ADC. Uses the typical 1175 mV reference, so expect + a few % absolute error; adjust `ADC_VREF_MV` against a meter if needed. + +## Build + +```sh +git clone https://github.com/pvvx/ATC_MiThermometer ../ATC_MiThermometer +# TC32 toolchain (Linux): e.g. https://github.com/flyskywhy/tc32 branch "linux" +ln -s /path/to/tc32 ../ATC_MiThermometer/SDK/tools/linux/tc32 +make +``` + +Produces `lywsd03mmc_b15_bthome.bin` (with the Telink CRC appended). + +## Flashing + +- **First install** (from stock Xiaomi or pvvx firmware, which have OTA): + https://pvvx.github.io/ATC_MiThermometer/TelinkMiFlasher.html → connect → + "Select Firmware" → custom file. Units running the previous build of this + firmware (no OTA) need one wired flash: USB-UART adapter on the SWS pad, + `TlsrComProg`/`ATCtelink.py` from the pvvx repo. +- **Updates** (this firmware → any Telink image ≤ 128 KiB, including back to + pvvx or stock), with https://pvvx.github.io/ATC_MiThermometer/TelinkOTA.html: + 1. Take the battery out, wait ~5 s, put it back. The BLE symbol on the + LCD means the OTA window is open: for 2 minutes the device accepts + connections and advertises every second. + 2. TelinkOTA.html → connect → pick `LYWSD03MMC` → select the `.bin` → + start. The upload must start within the 2 minutes; once started it may + run past them. + 3. The device reboots into the new firmware. If the window closes or the + upload fails, start again from step 1. + + All units are named `LYWSD03MMC`; only the unit you just power-cycled is + connectable, so it is the only one the page can connect to. + + The new image is written to the inactive flash bank and only activated + after its CRC checks out, so an interrupted upload leaves the current + firmware running. The transfer aborts after 16 s without data. + + The window opens only on a real power cycle. Reboots without one + (low-battery sleep, OTA reboot, crash) keep it closed. After installing + this firmware by OTA from pvvx firmware, power-cycle once before the next + update. + +**OTA itself is unauthenticated**: anyone in Bluetooth range can upload +firmware while the window is open, so it is limited to 2 minutes after a +battery swap, which needs physical access. diff --git a/makefile b/makefile new file mode 100644 index 0000000..3551867 --- /dev/null +++ b/makefile @@ -0,0 +1,89 @@ +# LYWSD03MMC B1.5 BTHome v2 beacon + LCD +# +# Builds against the Telink TLSR825x BLE SDK as shipped in +# pvvx/ATC_MiThermometer, reusing its startup code, linker script and +# post-build scripts: +# git clone https://github.com/pvvx/ATC_MiThermometer ../ATC_MiThermometer +# make ATC_PATH=../ATC_MiThermometer +# TC32 toolchain: $(TEL_PATH)/tools/linux/tc32/bin (or set TC32_PATH) + +PROJECT_NAME ?= LYWSD03MMC-BTHome +ATC_PATH ?= ../ATC_MiThermometer +TEL_PATH ?= $(ATC_PATH)/SDK +TC32_PATH ?= $(TEL_PATH)/tools/linux/tc32/bin/ +PYTHON ?= python3 + +SRC_PATH := ./src +OUT_PATH := ./out +ATC_SRC := $(ATC_PATH)/src + +ifeq ($(wildcard $(TEL_PATH)/components/drivers/8258/gpio_8258.c),) +$(error SDK not found: set ATC_PATH or TEL_PATH) +endif + +CC := $(TC32_PATH)tc32-elf-gcc +LD := $(TC32_PATH)tc32-elf-ld +OBJCOPY := $(TC32_PATH)tc32-elf-objcopy +NM := $(TC32_PATH)tc32-elf-nm + +CFLAGS := -DCHIP_TYPE=CHIP_TYPE_8258 -DMCU_STARTUP_825X \ + -ffunction-sections -fdata-sections -Wall -O2 -fpack-struct \ + -fshort-enums -finline-small-functions -std=gnu99 -funsigned-char \ + -fshort-wchar -fms-extensions \ + -I$(TEL_PATH)/components -I$(SRC_PATH) + +APP_SRCS := main.c ble.c i2c.c sensor.c battery.c lcd.c +DRV_SRCS := adc.c analog.c bsp.c clock.c flash.c gpio_8258.c i2c.c lpc.c rf_pa.c timer.c uart.c +COM_SRCS := string.c utility.c + +OBJS := \ + $(OUT_PATH)/boot/cstartup_825x.o \ + $(OUT_PATH)/boot/div_mod.o \ + $(addprefix $(OUT_PATH)/app/,$(APP_SRCS:.c=.o)) \ + $(addprefix $(OUT_PATH)/drv/,$(DRV_SRCS:.c=.o)) \ + $(addprefix $(OUT_PATH)/common/,$(COM_SRCS:.c=.o)) \ + $(OUT_PATH)/vendor/blt_common.o + +ELF := $(OUT_PATH)/$(PROJECT_NAME).elf +BIN := $(PROJECT_NAME).bin + +all: $(BIN) + +$(OUT_PATH)/app/%.o: $(SRC_PATH)/%.c $(wildcard $(SRC_PATH)/*.h) + @mkdir -p $(dir $@) + $(CC) $(CFLAGS) -c -o $@ $< + +$(OUT_PATH)/drv/%.o: $(TEL_PATH)/components/drivers/8258/%.c + @mkdir -p $(dir $@) + $(CC) $(CFLAGS) -c -o $@ $< + +$(OUT_PATH)/common/%.o: $(TEL_PATH)/components/common/%.c + @mkdir -p $(dir $@) + $(CC) $(CFLAGS) -c -o $@ $< + +$(OUT_PATH)/vendor/%.o: $(TEL_PATH)/components/vendor/common/%.c + @mkdir -p $(dir $@) + $(CC) $(CFLAGS) -c -o $@ $< + +$(OUT_PATH)/boot/%.o: $(ATC_SRC)/%.S + @mkdir -p $(dir $@) + $(CC) $(CFLAGS) -c -o $@ $< + +# Two-pass link: the second pass places code after the retention RAM area +$(ELF): $(OBJS) + $(LD) --gc-sections -L $(TEL_PATH)/components/proj_lib -T $(ATC_SRC)/boot.link \ + -o $@ $(OBJS) -llt_8258 + $(LD) --gc-sections -Ttext `$(PYTHON) $(ATC_SRC)/TlsrRetMemAddr.py -e $@ -t $(NM)` \ + -L $(TEL_PATH)/components/proj_lib -T $(ATC_SRC)/boot.link \ + -o $@ $(OBJS) -llt_8258 + @$(PYTHON) $(ATC_SRC)/TlsrMemInfo.py -s 32768 -t $(NM) $@ + +# Raw image + Telink CRC (required by the OTA bootloader) +$(BIN): $(ELF) + $(OBJCOPY) -O binary $< $@ + $(PYTHON) $(ATC_PATH)/utils/tl_check_fw.py $@ + +clean: + rm -rf $(OUT_PATH) $(BIN) + +.PHONY: all clean diff --git a/src/app_config.h b/src/app_config.h new file mode 100644 index 0000000..bbc9d8f --- /dev/null +++ b/src/app_config.h @@ -0,0 +1,97 @@ +/* + * Board / build configuration: Xiaomi LYWSD03MMC, hardware B1.5 ONLY + * as sold since 2025 (pvvx reports it as HW B1.1): TLSR8251F512ET24, + * SHTV3 sensor, SPI-driven LCD controller + * + * Pin map (from pvvx/ATC_MiThermometer reverse engineering): + * PC2 I2C SCL (SHTV3 sensor, 0x70), 10k pull-up + * PC3 I2C SDA, 10k pull-up + * PB6 LCD controller pin, must be pulled up (10k) + * PB0 not bonded on this package: driven high and sampled by the ADC + * to measure VDD (= CR2032 voltage) + * PD7 LCD SPI-CLK + * PB7 LCD SPI-SDI + */ +#pragma once + +#define CLOCK_SYS_CLOCK_HZ 24000000 +#define SYS_CLK_TYPE SYS_CLK_24M_Crystal +enum { + CLOCK_SYS_CLOCK_1S = CLOCK_SYS_CLOCK_HZ, + CLOCK_SYS_CLOCK_1MS = (CLOCK_SYS_CLOCK_1S / 1000), + CLOCK_SYS_CLOCK_1US = (CLOCK_SYS_CLOCK_1S / 1000000), +}; + +/* Low-power busy wait: CPU stalled, woken by timer0 */ +#define pm_wait_us(t) cpu_stall_wakeup_by_timer0((t) * CLOCK_SYS_CLOCK_1US) +#define pm_wait_ms(t) cpu_stall_wakeup_by_timer0((t) * CLOCK_SYS_CLOCK_1MS) + +/* Variables that must survive deep-sleep retention */ +#define RAM _attribute_data_retention_ + +/* ---- Application settings (equivalents of the Zephyr build) ---- */ +#define DEV_NAME "LYWSD03MMC" /* CONFIG_BT_DEVICE_NAME */ +#define ADV_INTERVAL 16000 /* 10000 ms / 0.625 ms */ +#define ADV_INTERVAL_DELAY 10 /* + up to 6.25 ms, as pvvx */ +#define MEAS_EVERY_N_ADV 6 /* measure every 6th adv = 60 s */ +#define MEAS_INTERVAL_S ((ADV_INTERVAL * MEAS_EVERY_N_ADV) / 1600) +#define RF_TX_POWER RF_POWER_P0p04dBm /* CONFIG_BT_CTLR_TX_PWR_0 */ +#define LOW_BATT_MV 2400 /* show battery icon below this */ + +/* Local name in the advertising packet as well as the scan response. + * 1: passive scanners (e.g. ESPHome with active: false) see the name. + * 0 (pvvx default): name only in the scan response; every advertising + * packet is 12 bytes shorter, but only active scanners see the name. */ +#define ADV_NAME_IN_ADV 1 + +/* ---- OTA window ---- + * Connections (and with them OTA) are only accepted for OTA_WINDOW_S after + * the battery is inserted. Then the device switches to non-connectable + * advertising until the next power cycle. A reboot without a power cycle + * (low-battery sleep, OTA reboot, crash) does not reopen the window. */ +#define OTA_WINDOW_S 120 +#define OTA_WINDOW_ADV_INTERVAL 1600 /* 1 s while the window is open */ + +/* ---- Connection / OTA (values from pvvx ble.h / app.c) ---- */ +#define CONN_INTERVAL 16 /* 16 * 1.25 ms = 20 ms */ +#define CONN_LATENCY 49 /* (49 + 1) * 20 ms = 1 s between radio events */ +#define CONN_TIMEOUT 400 /* x10 ms = 4 s */ +#define OTA_IDLE_TIMEOUT_US (16 * 1000000) /* abort OTA after 16 s without data */ + + +/* ---- I2C ---- */ +#define I2C_GROUP I2C_GPIO_GROUP_C2C3 +#define I2C_SCL GPIO_PC2 +#define I2C_SDA GPIO_PC3 +#define I2C_SPEED_HZ 700000 /* as pvvx I2C_MAX_SPEED; SHTC3 allows 1 MHz */ +#define PULL_I2C PM_PIN_PULLUP_10K + +/* ---- Battery ADC ---- */ +#define GPIO_VBAT GPIO_PB0 +#define ADC_VBAT_CHN B0P +#define ADC_VREF_MV 1175 /* typical internal 1.2 V ref, mV */ +/* PB0 (not bonded) is driven high permanently, as in pvvx: no toggling + * around each ADC read, and the pad never floats. Applied by gpio_init() + * on every wake-up, including from deep-sleep retention. */ +#define PB0_INPUT_ENABLE 0 +#define PB0_OUTPUT_ENABLE 1 +#define PB0_DATA_OUT 1 +#define PB0_FUNC AS_GPIO + +/* ---- LCD ---- */ +#define GPIO_LCD_PB6 GPIO_PB6 +#define PULL_LCD_PB6 PM_PIN_PULLUP_10K +#define GPIO_LCD_CLK GPIO_PD7 /* SPI clock to the LCD controller */ +#define GPIO_LCD_SDI GPIO_PB7 /* SPI data to the LCD controller */ +#define PULL_LCD_SER PM_PIN_PULLUP_1M + +/* Unused "reset" pad: keep it from floating */ +#define GPIO_KEY GPIO_PA5 +#define PULL_KEY PM_PIN_PULLUP_1M + +#define MODULE_WATCHDOG_ENABLE 0 + +/* Stock Xiaomi flash layout: factory MAC address lives here */ +#define CFG_ADR_MAC 0x76000 + +#include "vendor/common/default_config.h" diff --git a/src/battery.c b/src/battery.c new file mode 100644 index 0000000..840f28a --- /dev/null +++ b/src/battery.c @@ -0,0 +1,68 @@ +/* + * VDD measurement. PB0 is not bonded out on the TLSR8251 package; it is + * kept driven high (= VDD) permanently by gpio_init() (see app_config.h, + * same as pvvx) and sampled through the 1/8 prescaler against the + * 1.2 V reference. 8 samples are taken, sorted, and the middle 4 averaged. + * ADC sequence follows pvvx/ATC_MiThermometer battery.c. + */ +#include "tl_common.h" +#include "drivers.h" +#include "app_config.h" +#include "battery.h" + +#define ADC_SAMPLES 8 + +/* ADC analog config is not kept across deep-sleep retention: set it up each time */ +static void vbat_adc_setup(void) +{ + adc_power_on_sar_adc(0); + adc_set_sample_clk(5); + adc_set_left_right_gain_bias(GAIN_STAGE_BIAS_PER100, GAIN_STAGE_BIAS_PER100); + adc_set_chn_enable_and_max_state_cnt(ADC_MISC_CHN, 2); + adc_set_state_length(240, 0, 10); + analog_write(anareg_adc_res_m, RES14 | FLD_ADC_EN_DIFF_CHN_M); + adc_set_ain_chn_misc(ADC_VBAT_CHN, GND); + adc_set_ref_voltage(ADC_MISC_CHN, ADC_VREF_1P2V); + adc_set_tsample_cycle_chn_misc(SAMPLING_CYCLES_6); + adc_set_ain_pre_scaler(ADC_PRESCALER_1F8); +} + +_attribute_ram_code_ +u16 battery_read_mv(void) +{ + volatile u32 dat[ADC_SAMPLES]; + u16 s[ADC_SAMPLES]; + u8 rp = 0; + + vbat_adc_setup(); + adc_power_on_sar_adc(1); + adc_reset_adc_module(); + for (int i = 0; i < ADC_SAMPLES; i++) { + dat[i] = 0; + } + adc_config_misc_channel_buf((u16 *)dat, sizeof(dat)); + dfifo_enable_dfifo2(); + + for (int i = 0; i < ADC_SAMPLES; i++) { + while (rp == reg_dfifo2_wptr) { + } + rp = reg_dfifo2_wptr; + /* 14-bit result, bit 13 = sign in differential mode */ + u16 v = (dat[i] & BIT(13)) ? 0 : (dat[i] & 0x1FFF); + int j = i - 1; + + while (j >= 0 && s[j] > v) { /* insertion sort */ + s[j + 1] = s[j]; + j--; + } + s[j + 1] = v; + } + + dfifo_disable_dfifo2(); + adc_power_on_sar_adc(0); + + /* mV = avg/8192 * Vref * 8 = sum4 * Vref / 4096 */ + u32 sum = s[2] + s[3] + s[4] + s[5]; + + return (u16)((sum * ADC_VREF_MV + 2048) >> 12); +} diff --git a/src/battery.h b/src/battery.h new file mode 100644 index 0000000..9c442d1 --- /dev/null +++ b/src/battery.h @@ -0,0 +1,12 @@ +#pragma once +#include "tl_common.h" + +/* Low-battery protection, thresholds as pvvx (battery.h, CR2032 build): + * below BOOT_VBAT_MV at power-on the device sleeps again before touching + * flash or the radio; below END_VBAT_MV while running it shows "Lo" and + * sleeps for LOW_VBAT_SLEEP_S, then retries. */ +#define BOOT_VBAT_MV 2200 +#define END_VBAT_MV 2000 +#define LOW_VBAT_SLEEP_S 180 + +u16 battery_read_mv(void); /* VDD (= CR2032) in mV */ diff --git a/src/ble.c b/src/ble.c new file mode 100644 index 0000000..f69986f --- /dev/null +++ b/src/ble.c @@ -0,0 +1,340 @@ +/* + * BLE peripheral: connectable advertising, GATT server with the Telink OTA + * service, and the connection / OTA power handling of + * pvvx/ATC_MiThermometer (ble.c, app_att.c), MIT-style license: + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software ... to deal in the Software without restriction. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. + * + * OTA itself (packet handling, CRC, flash writes, swapping the boot bank + * and rebooting) is done by the Telink BLE library: otaWrite()/otaRead(). + * The new image is written to the bank that is not running (0x00000 or + * 0x20000, 128 KiB each), so a failed transfer leaves the old firmware + * bootable. + */ +#include "tl_common.h" +#include "drivers.h" +#include "stack/ble/ble.h" +#include "stack/ble/service/ble_ll_ota.h" +#include "vendor/common/blt_common.h" +#include "app_config.h" +#include "ble.h" +#include "lcd.h" + +void bls_set_advertise_prepare(void *p); /* in the library, no header */ + +/* Peripheral Preferred Connection Parameters (not in this SDK's headers) */ +typedef struct { + u16 intervalMin; + u16 intervalMax; + u16 latency; + u16 timeout; +} gap_periConnectParams_t; + +/* ----- link-layer buffers (required by the library; sizes as pvvx) ----- */ +RAM u8 blt_rxfifo_b[64 * 8] = { 0 }; +RAM my_fifo_t blt_rxfifo = { 64, 8, 0, 0, blt_rxfifo_b, }; +RAM u8 blt_txfifo_b[40 * 16] = { 0 }; +RAM my_fifo_t blt_txfifo = { 40, 16, 0, 0, blt_txfifo_b, }; + +RAM u8 ble_connected; +RAM u8 ota_active; +RAM u8 ota_window; +RAM static u32 window_start; +#define OTA_WINDOW_TICKS ((u32)OTA_WINDOW_S * CLOCK_16M_SYS_TIMER_CLK_1S) + +static void close_window(void); +RAM static u8 conn_param_accepted; + +RAM static u8 mac_public[6]; +static u8 mac_random_static[6]; + +/* Scan response: complete local name DEV_NAME */ +#define NAME_LEN (sizeof(DEV_NAME) - 1) +RAM static u8 scan_rsp[2 + NAME_LEN]; + +/* ============================ GATT table ============================ */ + +enum { + ATT_H_START = 0, + + GAP_PS_H, /* 0x1800 Generic Access */ + GAP_DEVNAME_CD_H, + GAP_DEVNAME_DP_H, + GAP_APPEAR_CD_H, + GAP_APPEAR_DP_H, + GAP_CONNPAR_CD_H, + GAP_CONNPAR_DP_H, + + GATT_PS_H, /* 0x1801 Generic Attribute */ + GATT_SC_CD_H, + GATT_SC_DP_H, + GATT_SC_CCC_H, + + OTA_PS_H, /* Telink OTA */ + OTA_CD_H, + OTA_DP_H, + OTA_DESC_H, + + ATT_END_H, +}; + +static const u16 primary_service_uuid = GATT_UUID_PRIMARY_SERVICE; +static const u16 character_uuid = GATT_UUID_CHARACTER; +static const u16 ccc_uuid = GATT_UUID_CLIENT_CHAR_CFG; +static const u16 user_desc_uuid = GATT_UUID_CHAR_USER_DESC; + +/* GAP */ +static const u16 gap_service_uuid = SERVICE_UUID_GENERIC_ACCESS; +static const u16 devname_uuid = GATT_UUID_DEVICE_NAME; +static const u16 appearance_uuid = GATT_UUID_APPEARANCE; +static const u16 connpar_uuid = GATT_UUID_PERI_CONN_PARAM; +static const u16 appearance = 768; /* Generic Thermometer */ +static const gap_periConnectParams_t periConnParameters = { + CONN_INTERVAL, CONN_INTERVAL, CONN_LATENCY, CONN_TIMEOUT +}; +static const u8 devname_cd[5] = { + CHAR_PROP_READ, + U16_LO(GAP_DEVNAME_DP_H), U16_HI(GAP_DEVNAME_DP_H), + U16_LO(GATT_UUID_DEVICE_NAME), U16_HI(GATT_UUID_DEVICE_NAME) +}; +static const u8 appear_cd[5] = { + CHAR_PROP_READ, + U16_LO(GAP_APPEAR_DP_H), U16_HI(GAP_APPEAR_DP_H), + U16_LO(GATT_UUID_APPEARANCE), U16_HI(GATT_UUID_APPEARANCE) +}; +static const u8 connpar_cd[5] = { + CHAR_PROP_READ, + U16_LO(GAP_CONNPAR_DP_H), U16_HI(GAP_CONNPAR_DP_H), + U16_LO(GATT_UUID_PERI_CONN_PARAM), U16_HI(GATT_UUID_PERI_CONN_PARAM) +}; + +/* GATT */ +static const u16 gatt_service_uuid = SERVICE_UUID_GENERIC_ATTRIBUTE; +static const u16 service_change_uuid = GATT_UUID_SERVICE_CHANGE; +static u32 service_change_val; +RAM static u16 service_change_ccc; +static const u8 service_change_cd[5] = { + CHAR_PROP_INDICATE, + U16_LO(GATT_SC_DP_H), U16_HI(GATT_SC_DP_H), + U16_LO(GATT_UUID_SERVICE_CHANGE), U16_HI(GATT_UUID_SERVICE_CHANGE) +}; + +/* Telink OTA */ +static const u8 ota_service_uuid[16] = TELINK_OTA_UUID_SERVICE; +static const u8 ota_data_uuid[16] = TELINK_SPP_DATA_OTA; +static u8 ota_data; +static const u8 ota_name[] = { 'O', 'T', 'A' }; +static const u8 ota_cd[19] = { + CHAR_PROP_READ | CHAR_PROP_WRITE_WITHOUT_RSP, + U16_LO(OTA_DP_H), U16_HI(OTA_DP_H), + 0x12, 0x2B, 0x0d, 0x0c, 0x0b, 0x0a, 0x09, 0x08, + 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, 0x00 +}; + + +/* Make the library's OTA timeout an inactivity timeout (pvvx otaWritePre): + * every OTA packet restarts it. */ +static int ota_write_pre(void *p) +{ + blt_ota_start_tick = clock_time() | 1; + return otaWrite(p); +} + +/* In RAM, as pvvx: the stack walks this table from interrupt context, + * which must not depend on flash reads while OTA is erasing/writing it. */ +RAM static attribute_t attributes[] = { + { ATT_END_H - 1, 0, 0, 0, 0, 0, 0, 0 }, /* total number of attributes */ + + { 7, ATT_PERMISSIONS_READ, 2, 2, (u8 *)&primary_service_uuid, (u8 *)&gap_service_uuid, 0, 0 }, + { 0, ATT_PERMISSIONS_READ, 2, sizeof(devname_cd), (u8 *)&character_uuid, (u8 *)devname_cd, 0, 0 }, + { 0, ATT_PERMISSIONS_READ, 2, NAME_LEN, (u8 *)&devname_uuid, &scan_rsp[2], 0, 0 }, + { 0, ATT_PERMISSIONS_READ, 2, sizeof(appear_cd), (u8 *)&character_uuid, (u8 *)appear_cd, 0, 0 }, + { 0, ATT_PERMISSIONS_READ, 2, sizeof(appearance), (u8 *)&appearance_uuid, (u8 *)&appearance, 0, 0 }, + { 0, ATT_PERMISSIONS_READ, 2, sizeof(connpar_cd), (u8 *)&character_uuid, (u8 *)connpar_cd, 0, 0 }, + { 0, ATT_PERMISSIONS_READ, 2, sizeof(periConnParameters), (u8 *)&connpar_uuid, (u8 *)&periConnParameters, 0, 0 }, + + { 4, ATT_PERMISSIONS_READ, 2, 2, (u8 *)&primary_service_uuid, (u8 *)&gatt_service_uuid, 0, 0 }, + { 0, ATT_PERMISSIONS_READ, 2, sizeof(service_change_cd), (u8 *)&character_uuid, (u8 *)service_change_cd, 0, 0 }, + { 0, ATT_PERMISSIONS_READ, 2, sizeof(service_change_val), (u8 *)&service_change_uuid, (u8 *)&service_change_val, 0, 0 }, + { 0, ATT_PERMISSIONS_RDWR, 2, sizeof(service_change_ccc), (u8 *)&ccc_uuid, (u8 *)&service_change_ccc, 0, 0 }, + + { 4, ATT_PERMISSIONS_READ, 2, 16, (u8 *)&primary_service_uuid, (u8 *)ota_service_uuid, 0, 0 }, + { 0, ATT_PERMISSIONS_READ, 2, sizeof(ota_cd), (u8 *)&character_uuid, (u8 *)ota_cd, 0, 0 }, + { 0, ATT_PERMISSIONS_RDWR, 16, sizeof(ota_data), (u8 *)ota_data_uuid, &ota_data, &ota_write_pre, &otaRead }, + { 0, ATT_PERMISSIONS_READ, 2, sizeof(ota_name), (u8 *)&user_desc_uuid, (u8 *)ota_name, 0, 0 }, + +}; + +/* ======================= connection / OTA events ======================= */ + +/* OTA start command received: full radio duty until it is done */ +static void ota_start_cb(void) +{ + ota_active = 1; + conn_param_accepted = 0; + bls_pm_setManualLatency(0); + bls_ota_setTimeout(OTA_IDLE_TIMEOUT_US); +} + +/* After a successful OTA the library reboots into the new image itself; + * this only matters for failures. */ +static void ota_result_cb(int result) +{ + (void)result; + ota_active = 0; +} + +/* Ask for 20 ms interval + latency 49: the device then only has to wake for + * one connection event per second while idle (pvvx ble_connect_callback). */ +static void connect_cb(u8 e, u8 *p, int n) +{ + (void)e; (void)p; (void)n; + ble_connected = 1; + conn_param_accepted = 0; + bls_l2cap_setMinimalUpdateReqSendingTime_after_connCreate(1000); + bls_l2cap_requestConnParamUpdate(CONN_INTERVAL, CONN_INTERVAL, + CONN_LATENCY, CONN_TIMEOUT); + lcd_show_ble(1); +} + +static void disconnect_cb(u8 e, u8 *p, int n) +{ + (void)e; (void)p; (void)n; + ble_connected = 0; + ota_active = 0; + lcd_show_ble(ota_window); + /* the stack resumes advertising by itself; ble_task() switches it to + * non-connectable if the window has expired meanwhile */ +} + +static int conn_param_rsp_cb(u8 id, u16 result) +{ + (void)id; + if (result == CONN_PARAM_UPDATE_ACCEPT) { + conn_param_accepted = 1; + } else if (result == CONN_PARAM_UPDATE_REJECT) { + bls_l2cap_requestConnParamUpdate(CONN_INTERVAL, CONN_INTERVAL, + CONN_LATENCY, CONN_TIMEOUT); + } + return 0; +} + +/* Suspend loses the RF power setting (pvvx suspend_exit_cb) */ +_attribute_ram_code_ +static void suspend_exit_cb(u8 e, u8 *p, int n) +{ + (void)e; (void)p; (void)n; + rf_set_power_level_index(RF_TX_POWER); +} + +/* ============================== init ============================== */ + +void ble_init(const u8 *adv, int adv_len, int (*adv_prepare)(void *), + int open_window) +{ + /* Factory public address (A4:C1:38:...) from the stock flash layout */ + blc_initMacAddress(CFG_ADR_MAC, mac_public, mac_random_static); + + memcpy(&scan_rsp[2], DEV_NAME, NAME_LEN); + scan_rsp[0] = NAME_LEN + 1; + scan_rsp[1] = GAP_ADTYPE_LOCAL_NAME_COMPLETE; + + /* Controller */ + blc_ll_initStandby_module(mac_public); + blc_ll_initAdvertising_module(mac_public); + blc_ll_initConnection_module(); + blc_ll_initSlaveRole_module(); + + /* Host */ + blc_gap_peripheral_init(); + bls_att_setAttributeTable((u8 *)attributes); + blc_l2cap_register_handler(blc_l2cap_packet_receive); + blc_smp_setSecurityLevel(No_Security); + + rf_set_power_level_index(RF_TX_POWER); + bls_app_registerEventCallback(BLT_EV_FLAG_CONNECT, &connect_cb); + bls_app_registerEventCallback(BLT_EV_FLAG_TERMINATE, &disconnect_cb); + bls_app_registerEventCallback(BLT_EV_FLAG_SUSPEND_EXIT, &suspend_exit_cb); + blc_l2cap_registerConnUpdateRspCb(conn_param_rsp_cb); + + /* OTA: erase the inactive bank if it still holds an old image */ + bls_ota_clearNewFwDataArea(); + bls_ota_registerStartCmdCb(ota_start_cb); + bls_ota_registerResultIndicateCb(ota_result_cb); + + /* Advertising */ + bls_ll_setAdvData((u8 *)adv, adv_len); + bls_ll_setScanRspData(scan_rsp, sizeof(scan_rsp)); + bls_set_advertise_prepare((void *)adv_prepare); + if (open_window) { + /* 1 s interval so the flashing page finds and connects quickly */ + ota_window = 1; + window_start = clock_time(); + bls_ll_setAdvParam(OTA_WINDOW_ADV_INTERVAL, + OTA_WINDOW_ADV_INTERVAL + ADV_INTERVAL_DELAY, + ADV_TYPE_CONNECTABLE_UNDIRECTED, OWN_ADDRESS_PUBLIC, + 0, NULL, BLT_ENABLE_ADV_ALL, ADV_FP_NONE); + bls_ll_setAdvEnable(BLC_ADV_ENABLE); + lcd_show_ble(1); /* BLE symbol = OTA window open */ + } else { + close_window(); + } + + /* Power management (pvvx init_ble) */ + blc_ll_initPowerManagement_module(); + blc_pm_setDeepsleepRetentionThreshold(40, 18); + blc_pm_setDeepsleepRetentionEarlyWakeupTiming(240); + blc_pm_setDeepsleepRetentionType(DEEPSLEEP_MODE_RET_SRAM_LOW32K); + bls_pm_setSuspendMask(SUSPEND_ADV | DEEPSLEEP_RETENTION_ADV | + SUSPEND_CONN | DEEPSLEEP_RETENTION_CONN); + bls_pm_setManualLatency(0); +} + +/* Called every main-loop pass. During OTA: answer every connection event + * (no slave latency) and never use deep-sleep retention, which would drop + * the transfer state. Otherwise allow the deepest sleep everywhere. */ +/* Window over: non-connectable advertising at the normal interval. In + * this mode the radio does not listen after each packet. */ +static void close_window(void) +{ + ota_window = 0; + bls_ll_setAdvParam(ADV_INTERVAL, ADV_INTERVAL + ADV_INTERVAL_DELAY, + ADV_TYPE_NONCONNECTABLE_UNDIRECTED, OWN_ADDRESS_PUBLIC, + 0, NULL, BLT_ENABLE_ADV_ALL, ADV_FP_NONE); + bls_ll_setAdvEnable(BLC_ADV_ENABLE); + lcd_show_ble(0); +} + +_attribute_ram_code_ +int ble_task(void) +{ + /* An upload that started inside the window may finish after it; any + * other connection is dropped when the window ends. */ + if (ota_window && !ota_active && + clock_time() - window_start > OTA_WINDOW_TICKS) { + if (ble_connected) { + bls_ll_terminateConnection(HCI_ERR_REMOTE_USER_TERM_CONN); + } else { + close_window(); + } + } + if (ota_active) { + if (!conn_param_accepted) { + bls_pm_setManualLatency(0); + } + bls_pm_setSuspendMask(SUSPEND_ADV | SUSPEND_CONN); + return 1; + } + bls_pm_setSuspendMask(SUSPEND_ADV | DEEPSLEEP_RETENTION_ADV | + SUSPEND_CONN | DEEPSLEEP_RETENTION_CONN); + return 0; +} + +_attribute_ram_code_ +void ble_init_deep_retention(void) +{ + rf_set_power_level_index(RF_TX_POWER); + blc_ll_recoverDeepRetention(); + bls_ota_registerStartCmdCb(ota_start_cb); /* as pvvx */ +} diff --git a/src/ble.h b/src/ble.h new file mode 100644 index 0000000..79edf72 --- /dev/null +++ b/src/ble.h @@ -0,0 +1,24 @@ +#pragma once +#include "tl_common.h" + +/* Connectable advertising + GATT server with the Telink OTA service + * (00010203-0405-0607-0809-0a0b0c0d1912), structured like + * pvvx/ATC_MiThermometer ble.c / app_att.c. */ + +extern u8 ble_connected; /* a central is connected */ +extern u8 ota_active; /* OTA transfer in progress */ +extern u8 ota_window; /* connectable OTA window open */ + +/* Called once from user_init_normal(). open_window: battery was just + * inserted, accept connections for OTA_WINDOW_S; otherwise go straight to + * non-connectable advertising. adv_prepare is called by the stack + * right before every advertising event (pvvx app_advertise_prepare_handler): + * the application piggybacks its periodic work on those wake-ups. */ +void ble_init(const u8 *adv, int adv_len, int (*adv_prepare)(void *), + int open_window); + +/* Every main-loop pass: sets the sleep mode; returns 1 while OTA runs */ +int ble_task(void); + +/* Restore what deep-sleep retention loses (from user_init_deepRetn) */ +void ble_init_deep_retention(void); diff --git a/src/i2c.c b/src/i2c.c new file mode 100644 index 0000000..b9f8a08 --- /dev/null +++ b/src/i2c.c @@ -0,0 +1,61 @@ +#include "tl_common.h" +#include "drivers.h" +#include "app_config.h" +#include "i2c.h" + +#define I2C_WAIT() while (reg_i2c_status & FLD_I2C_CMD_BUSY) + +/* The I2C clock and pin mux live in digital registers that are lost in + * deep-sleep retention, so this is called at the start of every bus session. + * Clock stretching is disabled: the sensor is polled, never stretched. */ +_attribute_ram_code_ +void i2c_bus_init(void) +{ + i2c_gpio_set(I2C_GROUP); + reg_i2c_speed = CLOCK_SYS_CLOCK_HZ / (4 * I2C_SPEED_HZ); + reg_i2c_mode |= FLD_I2C_MASTER_EN; + reg_i2c_mode &= ~FLD_I2C_HOLD_MASTER; + reg_clk_en0 |= FLD_CLK0_I2C_EN; + reg_spi_sp &= ~FLD_SPI_ENABLE; /* pads shared with SPI: force I2C */ +} + +_attribute_ram_code_ +int i2c_write(u8 addr, const u8 *buf, u32 len) +{ + int err; + + reg_i2c_id = addr << 1; + reg_i2c_ctrl = FLD_I2C_CMD_START | FLD_I2C_CMD_ID; + I2C_WAIT(); + err = reg_i2c_status & FLD_I2C_NAK; + while (!err && len--) { + reg_i2c_do = *buf++; + reg_i2c_ctrl = FLD_I2C_CMD_DO; + I2C_WAIT(); + err = reg_i2c_status & FLD_I2C_NAK; + } + reg_i2c_ctrl = FLD_I2C_CMD_STOP; + I2C_WAIT(); + return err; +} + +_attribute_ram_code_ +int i2c_read(u8 addr, u8 *buf, u32 len) +{ + int err; + + reg_i2c_id = (addr << 1) | FLD_I2C_WRITE_READ_BIT; + reg_i2c_ctrl = FLD_I2C_CMD_START | FLD_I2C_CMD_ID; + I2C_WAIT(); + err = reg_i2c_status & FLD_I2C_NAK; + while (!err && len--) { + /* NAK (ACK flag) the last byte to end the read */ + reg_i2c_ctrl = FLD_I2C_CMD_DI | FLD_I2C_CMD_READ_ID | + (len ? 0 : FLD_I2C_CMD_ACK); + I2C_WAIT(); + *buf++ = reg_i2c_di; + } + reg_i2c_ctrl = FLD_I2C_CMD_STOP; + I2C_WAIT(); + return err; +} diff --git a/src/i2c.h b/src/i2c.h new file mode 100644 index 0000000..46df685 --- /dev/null +++ b/src/i2c.h @@ -0,0 +1,8 @@ +#pragma once +#include "tl_common.h" + +/* Polled I2C master on the TLSR825x hardware block. Addresses are 7-bit. + * All functions return 0 on success, non-zero on NAK. */ +void i2c_bus_init(void); +int i2c_write(u8 addr, const u8 *buf, u32 len); +int i2c_read(u8 addr, u8 *buf, u32 len); diff --git a/src/lcd.c b/src/lcd.c new file mode 100644 index 0000000..43ac035 --- /dev/null +++ b/src/lcd.c @@ -0,0 +1,209 @@ +/* + * LYWSD03MMC segment LCD driver for the 2025 "B1.5" board (pvvx reports it + * as HW B1.1): LCD controller on bit-banged SPI, PD7 = CLK, PB7 = SDI. + * Write-only, no acknowledge. + * + * The frame format and segment map are from pvvx/ATC_MiThermometer + * (lcd_lywsd03mmc.c), used under that project's MIT-style license: + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software ... to deal in the Software without restriction. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. + * + * Display buffer, byte.bit: + * + * --5.4-- --4.4-- --3.4-- BLE 2.4 + * | 5.5 5.0 4.5 4.0 3.5 3.0 o 2.5 (temp unit + * 5.3 --5.1-- --4.1-- --3.1-- 2.5..2.7 symbols) + * | 5.6 5.2 4.6 4.2 3.6 3.2 + * --5.7-- --4.7-- * --3.7-- + * 4.3 + * smiley 2.0..2.2 --1.4-- --0.4-- + * 1.5 1.0 0.5 0.0 + * --1.1-- --0.1-- + * 1.6 1.2 0.6 0.2 % 0.3 + * BAT 1.3 --1.7-- --0.7-- + */ +#include "tl_common.h" +#include "drivers.h" +#include "drivers/8258/gpio_8258.h" +#include "app_config.h" +#include "lcd.h" + +#define SEG_MINUS 0x02 /* middle segment */ +#define SYM_POINT 0x08 /* 4.3 */ +#define SYM_ONE 0x08 /* 5.3: leading "1" for 100..199 */ +#define SYM_PERCENT 0x08 /* 0.3 */ +#define SYM_BAT 0x08 /* 1.3 */ +#define SYM_DEG_C 0xA0 /* 2.5 + 2.7 */ +#define SYM_BLE 0x10 /* 2.4: shown while a BLE central is connected */ +#define SYM_H 0x67 +#define SYM_i 0x40 +#define SYM_L 0xE0 +#define SYM_o 0xC6 + +#define SPI_CLK_DELAY_US 32 /* half clock period: ~15 kHz */ + +static const u8 digits[10] = { + 0xF5, 0x05, 0xD3, 0x97, 0x27, 0xB6, 0xF6, 0x15, 0xF7, 0xB7 +}; + +/* Frame: AA d5..d0 xor(d) 55, sent byte by byte, LSB first */ +RAM static u8 buf[6]; +RAM static u8 sent[6]; +RAM static u8 frame[9]; + +static void frame_fill(void) +{ + u8 chk = 0; + + frame[0] = 0xAA; + for (int i = 0; i < 6; i++) { + frame[1 + i] = buf[5 - i]; + chk ^= buf[i]; + } + frame[7] = chk; + frame[8] = 0x55; +} + +/* Data changes while CLK is low and is latched on the rising edge */ +_attribute_ram_code_ +static void spi_send_byte(u8 b) +{ + for (int i = 0; i < 8; i++) { + BM_CLR(reg_gpio_out(GPIO_LCD_CLK), GPIO_LCD_CLK & 0xFF); + if (b & 0x01) { + BM_SET(reg_gpio_out(GPIO_LCD_SDI), GPIO_LCD_SDI & 0xFF); + } else { + BM_CLR(reg_gpio_out(GPIO_LCD_SDI), GPIO_LCD_SDI & 0xFF); + } + sleep_us(SPI_CLK_DELAY_US); + BM_SET(reg_gpio_out(GPIO_LCD_CLK), GPIO_LCD_CLK & 0xFF); + sleep_us(SPI_CLK_DELAY_US); + b >>= 1; + } + sleep_us(SPI_CLK_DELAY_US); +} + +/* ~5 ms with interrupts off, only when the content changed. gpio_init() + * makes both pins inputs again after every deep-sleep retention wake-up, + * so they are switched to outputs on each send. */ +_attribute_ram_code_ +static void lcd_send(void) +{ + BM_CLR(reg_gpio_oen(GPIO_LCD_CLK), GPIO_LCD_CLK & 0xFF); + BM_CLR(reg_gpio_oen(GPIO_LCD_SDI), GPIO_LCD_SDI & 0xFF); + + u8 r = irq_disable(); + + for (int i = 0; i < (int)sizeof(frame); i++) { + spi_send_byte(frame[i]); + } + irq_restore(r); +} + +static void lcd_flush(void) +{ + if (!memcmp(sent, buf, sizeof(buf))) { + return; /* nothing changed: no LCD traffic at all */ + } + frame_fill(); + lcd_send(); + memcpy(sent, buf, sizeof(buf)); +} + +void lcd_init(void) +{ + memset(buf, SEG_MINUS, sizeof(buf)); /* "-- --" */ + buf[2] = 0; + + pm_wait_ms(30); /* controller start-up, as pvvx (>= 30 ms) */ + frame_fill(); + lcd_send(); + memcpy(sent, buf, sizeof(buf)); +} + +/* Big digits: temperature in 0.1 degC. + * -9.9 .. 199.9 shown with one decimal, -99 .. -10 as integers. */ +static void show_big_x10(s16 n) +{ + buf[5] = buf[4] = 0; + if (n > 1999) { + buf[3] = 0; buf[4] = SYM_i; buf[5] = SYM_H; /* "Hi" */ + return; + } + if (n <= -995) { + buf[3] = 0; buf[4] = SYM_o; buf[5] = SYM_L; /* "Lo" */ + return; + } + if (n < 0) { + n = -n; + buf[5] = SEG_MINUS; + } + if (buf[5] && n > 99) { + n = (n + 5) / 10; /* -10 .. -99: integer */ + } else { + buf[4] = SYM_POINT; + } + if (n > 999) { + buf[5] |= SYM_ONE; + } + if (n > 99) { + buf[5] |= digits[n / 100 % 10]; + } + buf[4] |= digits[n / 10 % 10]; /* always: "0.x" */ + buf[3] = digits[n % 10]; +} + +/* Small digits: humidity in %. */ +static void show_small_pct(int n) +{ + buf[1] &= SYM_BAT; + buf[0] = SYM_PERCENT; + if (n > 99) { + buf[0] |= SYM_i; buf[1] |= SYM_H; + return; + } + if (n < 0) { + n = 0; + } + if (n > 9) { + buf[1] |= digits[n / 10]; + } + buf[0] |= digits[n % 10]; +} + +void lcd_show(int valid, s16 t10, int rh, int batt_low) +{ + if (valid) { + show_big_x10(t10); + show_small_pct(rh); + buf[2] = (buf[2] & SYM_BLE) | SYM_DEG_C; + } + if (batt_low) { + buf[1] |= SYM_BAT; + } else { + buf[1] &= ~SYM_BAT; + } + lcd_flush(); +} + +void lcd_show_ble(int on) +{ + if (on) { + buf[2] |= SYM_BLE; + } else { + buf[2] &= ~SYM_BLE; + } + lcd_flush(); +} + +/* Dead-battery screen (as pvvx low_vbat()): battery voltage in the big + * digits ("1.9"), "Lo" in the small ones, battery icon on. */ +void lcd_show_low_battery(u16 mv) +{ + show_big_x10(mv / 100); + buf[1] = SYM_L | SYM_BAT; + buf[0] = SYM_o; + buf[2] = 0; + lcd_flush(); +} diff --git a/src/lcd.h b/src/lcd.h new file mode 100644 index 0000000..ef54162 --- /dev/null +++ b/src/lcd.h @@ -0,0 +1,11 @@ +#pragma once +#include "tl_common.h" + +/* Segment LCD of the LYWSD03MMC 2025 "B1.5" board (pvvx: HW B1.1): + * controller on bit-banged SPI, PD7 CLK / PB7 SDI, write-only. It holds + * the image by itself, so the CPU only talks to it when the content + * changes. */ +void lcd_init(void); /* shows "-- --" */ +void lcd_show(int valid, s16 t10, int rh, int batt_low); +void lcd_show_ble(int on); /* BLE symbol while connected */ +void lcd_show_low_battery(u16 mv); /* "x.x Lo" + battery icon */ diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..88b0c5a --- /dev/null +++ b/src/main.c @@ -0,0 +1,299 @@ +/* + * BTHome v2 temperature / humidity beacon with LCD and OTA - Xiaomi + * LYWSD03MMC B1.5 (Telink TLSR8251, Telink BLE SDK, no RTOS) + * + * Every 60 s: measure T/RH (SHTV3) and VDD, update the segment LCD and the + * BTHome v2 advertisement sent every 10 s. For OTA_WINDOW_S after the + * battery is inserted the advertising is connectable (1 s interval) so the + * Telink OTA service can be reached; after that it is non-connectable. + * + * Power handling follows pvvx/ATC_MiThermometer: + * - no wake-ups of its own: a measurement is started right after every + * 6th advertising event, while the chip is awake anyway + * - the CPU sleeps (suspend) during the ~12 ms sensor conversion instead + * of stalling; the result is collected by an app wake-up timer + * - VDD is sampled while the sensor converts, i.e. under load + * - deep-sleep retention between events, also while connected + * (slave latency 49 -> one connection event per second) + * - battery protection: < 2.2 V at power-on or < 2.0 V while running -> + * "Lo" on the LCD and 3 min deep sleep, before any flash write + */ +#include "tl_common.h" +#include "drivers.h" +#include "stack/ble/ble.h" +#include "vendor/common/blt_common.h" +#include "app_config.h" +#include "sensor.h" +#include "battery.h" +#include "lcd.h" +#include "ble.h" + +#define MEAS_INTERVAL_TICKS ((u32)MEAS_INTERVAL_S * CLOCK_16M_SYS_TIMER_CLK_1S) +#define NAME_LEN (sizeof(DEV_NAME) - 1) + +/* Advertising payload (objects in ascending ID order: 0x0C, 0x2E, 0x45) */ +enum { + OFS_VOLT = 9, /* voltage, 1 mV, uint16 */ + OFS_HUM = 12, /* humidity, 1 %, uint8 */ + OFS_TEMP = 14, /* temperature, 0.1 degC, sint16 */ +}; + +#if ADV_NAME_IN_ADV +#define ADV_LEN (18 + NAME_LEN) +#else +#define ADV_LEN 16 +#endif + +RAM static u8 adv[ADV_LEN] = { + 0x02, 0x01, 0x06, /* flags: LE general, no BR/EDR */ + 0x0C, 0x16, /* service data, 16-bit UUID */ + 0xD2, 0xFC, /* UUID 0xFCD2 */ + 0x40, /* BTHome v2, unencrypted, regular interval */ + 0x0C, 0, 0, /* voltage */ + 0x2E, 0, /* humidity */ + 0x45, 0, 0, /* temperature */ +#if ADV_NAME_IN_ADV + NAME_LEN + 1, 0x09, /* complete local name, filled in at boot */ +#endif +}; +typedef char adv_fits_31_bytes[(sizeof(adv) <= 31) ? 1 : -1]; + +/* Measurement state, kept through deep-sleep retention */ +RAM static u8 adv_count; /* advertising events since last measurement */ +RAM static u8 start_measure; /* measurement due */ +RAM static u8 publish_pending; /* new values to put on air / on the LCD */ +RAM static u8 valid; /* at least one good sensor reading */ +RAM static u32 time_measure; /* != 0: sensor converting since this tick */ +RAM static u32 meas_tick; /* last measurement start (used when connected) */ +RAM static s16 t10; +RAM static u8 rh_pct; +RAM static u16 vbat_mv; + +static inline void put_le16(u8 *p, u16 v) +{ + p[0] = v & 0xFF; + p[1] = v >> 8; +} + +/* Deep sleep without retention: the next wake-up is a fresh boot */ +static void deep_sleep_s(u32 s) +{ + cpu_sleep_wakeup(DEEPSLEEP_MODE, PM_WAKEUP_TIMER, + clock_time() + s * CLOCK_16M_SYS_TIMER_CLK_1S); + while (1) { + } +} + +/* pvvx low_vbat(): stop everything, show it, retry in 3 minutes */ +static void low_battery(u16 mv) +{ + sensor_sleep(); + lcd_show_low_battery(mv); + deep_sleep_s(LOW_VBAT_SLEEP_S); +} + +_attribute_ram_code_ +static void store_result(s32 mc, s32 mrh) +{ + s32 pct = (mrh + 500) / 1000; + + t10 = (mc + (mc < 0 ? -50 : 50)) / 100; + rh_pct = pct < 0 ? 0 : (pct > 100 ? 100 : pct); + valid = 1; +} + +/* App wake-up timer: the sensor conversion is finished (pvvx + * WakeupLowPowerCb). Also called from main_loop() if the stack did not + * sleep at all. A failed read keeps the last good T/RH values. */ +_attribute_ram_code_ +static void measure_done_cb(int unused) +{ + s32 mc, mrh; + + (void)unused; + if (!time_measure) { + return; + } + time_measure = 0; + bls_pm_setAppWakeupLowPower(0, 0); + if (!sensor_result(&mc, &mrh)) { + store_result(mc, mrh); + } + publish_pending = 1; +} + +/* Start a measurement: trigger the sensor, read VDD while it converts, + * then let the stack sleep until the result is ready. */ +_attribute_ram_code_ +static void measure_start(void) +{ + meas_tick = clock_time(); + if (!sensor_start()) { + time_measure = clock_time() | 1; + } + vbat_mv = battery_read_mv(); + if (vbat_mv < END_VBAT_MV) { + low_battery(vbat_mv); + } + if (time_measure) { + bls_pm_setAppWakeupLowPower(time_measure + SENSOR_MEAS_TICKS, 1); + } else { + publish_pending = 1; /* sensor absent: voltage only */ + } +} + +_attribute_ram_code_ +static void publish(void) +{ + publish_pending = 0; + if (valid) { + put_le16(&adv[OFS_TEMP], (u16)t10); + adv[OFS_HUM] = rh_pct; + } + put_le16(&adv[OFS_VOLT], vbat_mv); + bls_ll_setAdvData(adv, sizeof(adv)); + lcd_show(valid, t10, rh_pct, vbat_mv < LOW_BATT_MV); +} + +/* Called by the stack right before every advertising event (pvvx + * app_advertise_prepare_handler): count events, flag a measurement on + * every MEAS_EVERY_N_ADV-th one. The measurement itself starts in + * main_loop() once the event is done. While the OTA window runs at the + * fast interval, main_loop() uses a timer instead. */ +_attribute_ram_code_ +static int adv_prepare_cb(void *p) +{ + (void)p; + if (!ota_window && ++adv_count >= MEAS_EVERY_N_ADV) { + adv_count = 0; + start_measure = 1; + } + return 1; /* send this advertising packet */ +} + +static void user_init_normal(void) +{ + blc_ll_initBasicMCU(); + adc_power_on_sar_adc(0); + lpc_power_down(); + + /* Analog pull resistors are kept through deep sleep: set once */ + gpio_setup_up_down_resistor(I2C_SCL, PULL_I2C); + gpio_setup_up_down_resistor(I2C_SDA, PULL_I2C); + gpio_setup_up_down_resistor(GPIO_LCD_PB6, PULL_LCD_PB6); + gpio_setup_up_down_resistor(GPIO_LCD_CLK, PULL_LCD_SER); + gpio_setup_up_down_resistor(GPIO_LCD_SDI, PULL_LCD_SER); + gpio_setup_up_down_resistor(GPIO_KEY, PULL_KEY); + + /* Battery first: nothing below may write flash on a dying cell + * (pvvx start_tst_battery) */ + vbat_mv = battery_read_mv(); + if (vbat_mv < BOOT_VBAT_MV) { + sensor_init(); /* idle -> sleep */ + lcd_init(); + low_battery(vbat_mv); + } + + /* The library writes flash for OTA: clear write protection left by + * other firmware (as pvvx, once per power-on) */ + flash_unlock(); + random_generator_init(); + +#if ADV_NAME_IN_ADV + memcpy(&adv[18], DEV_NAME, NAME_LEN); +#endif + lcd_init(); /* shows "-- --" until the first good reading */ + sensor_init(); /* put the SHTV3 to sleep */ + + /* First measurement, blocking, so the first packet carries data */ + if (!sensor_start()) { + s32 mc, mrh; + + vbat_mv = battery_read_mv(); + pm_wait_us(SENSOR_MEAS_US); + if (!sensor_result(&mc, &mrh)) { + store_result(mc, mrh); + } + } + publish(); + + /* DEEP_ANA_REG0 survives deep sleep and software reboots and is + * cleared only by a power cycle (pvvx uses it the same way) */ + int power_on = analog_read(DEEP_ANA_REG0) != 0x55; + + analog_write(DEEP_ANA_REG0, 0x55); + + bls_pm_registerAppWakeupLowPowerCb(measure_done_cb); + ble_init(adv, sizeof(adv), adv_prepare_cb, power_on); + meas_tick = clock_time(); +} + +_attribute_ram_code_ +static void user_init_deepRetn(void) +{ + blc_ll_initBasicMCU(); + ble_init_deep_retention(); +} + +_attribute_ram_code_ +static void main_loop(void) +{ + blt_sdk_main_loop(); + + if (ble_task()) { + return; /* OTA in progress: no measurements */ + } + + if (time_measure && + clock_time() - time_measure > SENSOR_MEAS_TICKS) { + measure_done_cb(0); /* stack did not sleep in between */ + } + + /* While connected there are no advertising events to count, and in + * the OTA window they come every second */ + if ((ble_connected || ota_window) && + clock_time() - meas_tick >= MEAS_INTERVAL_TICKS) { + start_measure = 1; + } + + if (start_measure && !time_measure) { + start_measure = 0; + measure_start(); + } + + if (publish_pending) { + publish(); + } +} + +_attribute_ram_code_ +void irq_handler(void) +{ + irq_blt_sdk_handler(); +} + +_attribute_ram_code_ +int main(void) +{ + blc_pm_select_internal_32k_crystal(); /* no 32 kHz crystal fitted */ + cpu_wakeup_init(); + + int deep_ret = pm_is_MCU_deepRetentionWakeup(); + + gpio_init(!deep_ret); + clock_init(SYS_CLK_TYPE); + rf_drv_init(RF_MODE_BLE_1M); + reg_clk_en0 = FLD_CLK0_SWIRE_EN; /* gate unused peripheral clocks */ + blc_app_loadCustomizedParameters(); /* factory crystal trim */ + + if (deep_ret) { + user_init_deepRetn(); + } else { + user_init_normal(); + } + + irq_enable(); + for (;;) { + main_loop(); + } +} diff --git a/src/sensor.c b/src/sensor.c new file mode 100644 index 0000000..0cb097d --- /dev/null +++ b/src/sensor.c @@ -0,0 +1,94 @@ +#include "tl_common.h" +#include "drivers.h" +#include "app_config.h" +#include "i2c.h" +#include "sensor.h" + +/* SHTV3 / SHTC3 on the B1.5 board */ +#define SHTC3_ADDR 0x70 +#define SHTC3_WAKEUP_US 240 + +static const u8 shtc3_wakeup[] = { 0x35, 0x17 }; +static const u8 shtc3_sleep[] = { 0xB0, 0x98 }; +static const u8 shtc3_meas[] = { 0x78, 0x66 }; /* normal mode, T first, no stretching */ + +_attribute_ram_code_ +static u8 crc8(const u8 *p, int len) +{ + u8 crc = 0xFF; + + while (len--) { + crc ^= *p++; + for (int i = 0; i < 8; i++) { + crc = (crc & 0x80) ? (crc << 1) ^ 0x31 : (crc << 1); + } + } + return crc; +} + +/* Wake the sensor once and put it straight back to sleep (~0.3 uA). + * It powers up in idle mode, which draws far more than sleep. */ +int sensor_init(void) +{ + int err; + + i2c_bus_init(); + err = i2c_write(SHTC3_ADDR, shtc3_wakeup, sizeof(shtc3_wakeup)); + if (!err) { + sleep_us(SHTC3_WAKEUP_US); + i2c_write(SHTC3_ADDR, shtc3_sleep, sizeof(shtc3_sleep)); + } + return err; +} + +_attribute_ram_code_ +int sensor_start(void) +{ + int err; + + i2c_bus_init(); + err = i2c_write(SHTC3_ADDR, shtc3_wakeup, sizeof(shtc3_wakeup)); + if (err) { + return err; + } + sleep_us(SHTC3_WAKEUP_US); + err = i2c_write(SHTC3_ADDR, shtc3_meas, sizeof(shtc3_meas)); + if (err) { + i2c_write(SHTC3_ADDR, shtc3_sleep, sizeof(shtc3_sleep)); + } + return err; +} + +/* Raw 16-bit words -> milli-units. The x/65536 scaling is done as + * (x * k) >> 13, which fits in 32 bits: + * 175000 / 8 = 21875, 100000 / 8 = 12500 */ +_attribute_ram_code_ +int sensor_result(s32 *t_mc, s32 *rh_mpct) +{ + u8 buf[6]; + int err; + + i2c_bus_init(); /* I2C registers do not survive deep-sleep retention */ + err = i2c_read(SHTC3_ADDR, buf, sizeof(buf)); + i2c_write(SHTC3_ADDR, shtc3_sleep, sizeof(shtc3_sleep)); + + if (err || crc8(&buf[0], 2) != buf[2] || crc8(&buf[3], 2) != buf[5]) { + return -1; + } + + u32 t_raw = (buf[0] << 8) | buf[1]; + u32 h_raw = (buf[3] << 8) | buf[4]; + + *t_mc = (s32)((t_raw * 21875) >> 13) - 45000; + *rh_mpct = (s32)((h_raw * 12500) >> 13); + return 0; +} + +/* The sleep command is NAKed while a conversion is running, so allow + * for one to finish first. */ +void sensor_sleep(void) +{ + i2c_bus_init(); + sleep_us(SENSOR_MEAS_US); + i2c_write(SHTC3_ADDR, shtc3_sleep, sizeof(shtc3_sleep)); +} diff --git a/src/sensor.h b/src/sensor.h new file mode 100644 index 0000000..a01076d --- /dev/null +++ b/src/sensor.h @@ -0,0 +1,18 @@ +#pragma once +#include "tl_common.h" + +/* Temperature/humidity sensor of the LYWSD03MMC B1.5: + * SHTV3 (SHTC3-compatible) at I2C address 0x70. + * + * A measurement is split in two so the CPU can sleep while the sensor + * converts (as pvvx SENSOR_SLEEP_MEASURE does): + * sensor_start() wake the sensor and trigger a conversion + * ... SENSOR_MEAS_TICKS later ... + * sensor_result() read the result and put the sensor back to sleep */ +#define SENSOR_MEAS_US 12500 /* SHTC3 normal mode: max 12.1 ms */ +#define SENSOR_MEAS_TICKS (SENSOR_MEAS_US * CLOCK_16M_SYS_TIMER_CLK_1US) + +int sensor_init(void); /* 0 = sensor answered */ +int sensor_start(void); /* 0 = conversion started */ +int sensor_result(s32 *t_mc, s32 *rh_mpct); /* milli-degC, milli-%; 0 = ok */ +void sensor_sleep(void); /* force sleep (e.g. low battery) */