From 7ba38f1f0b4bd557fa26577d1d67082791871678 Mon Sep 17 00:00:00 2001 From: s-varna Date: Tue, 7 Jul 2026 14:13:48 +0100 Subject: [PATCH 01/15] DEV-818 Add BMP581 pressure sensor support hooks - ShimBrd_isBoardSrNumberGte() + ShimBrd_isBmp581PresentPerSrNumber() to identify up-rev'd Shimmer3R boards fitted with a BMP581 (>= SR31-11-2, SR47-8-2, SR48-8-2, SR49-4-2) - Add PRESSURE_SENSOR_BMP581 identity - NACK GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND when a BMP581 is fitted (self-compensating, no calibration coefficients), in keeping with the legacy BMP180/BMP280 command approach - Skip the 21-byte SD-header calibration block for the BMP581 - Widen the pressure oversampling clamp 0-5 -> 0-7 on BMP581 (64x/128x) - New SD cfg key pres_bmp581_prec (pres_bmp390_prec still parsed) Co-Authored-By: Claude Fable 5 --- Boards/shimmer_boards.c | 19 +++++++++++++++++++ Boards/shimmer_boards.h | 2 ++ Comms/shimmer_bt_uart.c | 23 +++++++++++++++++++++-- Comms/shimmer_bt_uart.h | 3 ++- Configuration/shimmer_config.c | 6 +++++- SDCard/shimmer_sd_cfg_file.c | 7 +++++-- SDCard/shimmer_sd_header.c | 9 +++++++-- 7 files changed, 61 insertions(+), 8 deletions(-) diff --git a/Boards/shimmer_boards.c b/Boards/shimmer_boards.c index 2287e6c3..c2799198 100644 --- a/Boards/shimmer_boards.c +++ b/Boards/shimmer_boards.c @@ -316,6 +316,25 @@ uint8_t ShimBrd_isBoardSrNumber(uint8_t exp_brd_id, uint8_t exp_brd_major, uint8 && daughterCardIdPage.expansion_brd.exp_brd_minor == exp_brd_minor)); } +uint8_t ShimBrd_isBoardSrNumberGte(uint8_t exp_brd_id, uint8_t exp_brd_major, uint8_t exp_brd_minor) +{ + return (ShimBrd_isDaughterCardIdSet() + && (daughterCardIdPage.expansion_brd.exp_brd_id == exp_brd_id + && daughterCardIdPage.expansion_brd.exp_brd_major >= exp_brd_major + && daughterCardIdPage.expansion_brd.exp_brd_minor >= exp_brd_minor)); +} + +uint8_t ShimBrd_isBmp581PresentPerSrNumber(void) +{ + /* The BMP581 replaces the BMP390 on up-rev'd Shimmer3R boards (DEV-818). + * Note the >= applies to both the major and minor revs. */ + return (ShimBrd_isHwId(HW_ID_SHIMMER3R) + && (ShimBrd_isBoardSrNumberGte(SHIMMER3_IMU, 11, 2) + || ShimBrd_isBoardSrNumberGte(EXP_BRD_EXG_UNIFIED, 8, 2) + || ShimBrd_isBoardSrNumberGte(EXP_BRD_GSR_UNIFIED, 8, 2) + || ShimBrd_isBoardSrNumberGte(EXP_BRD_BR_AMP_UNIFIED, 4, 2))); +} + uint8_t ShimBrd_isHwId(uint8_t hwIdToCheck) { return hwId == hwIdToCheck; diff --git a/Boards/shimmer_boards.h b/Boards/shimmer_boards.h index 2b928db8..11e6e9ee 100644 --- a/Boards/shimmer_boards.h +++ b/Boards/shimmer_boards.h @@ -102,6 +102,8 @@ uint8_t ShimBrd_isI2cOnPPGControlledByAdcChip(void); uint8_t ShimBrd_areMcuAdcsUsedForSensing(void); uint8_t ShimBrd_isBoardSrNumber(uint8_t exp_brd_id, uint8_t exp_brd_major, uint8_t exp_brd_minor); +uint8_t ShimBrd_isBoardSrNumberGte(uint8_t exp_brd_id, uint8_t exp_brd_major, uint8_t exp_brd_minor); +uint8_t ShimBrd_isBmp581PresentPerSrNumber(void); uint8_t ShimBrd_isHwId(uint8_t hwIdToCheck); uint8_t ShimBrd_isExpBrdId(uint8_t expIdToCheck); uint8_t ShimBrd_checkCorrectStateForBoot0(void); diff --git a/Comms/shimmer_bt_uart.c b/Comms/shimmer_bt_uart.c index 295a1fb9..76d8b5ff 100644 --- a/Comms/shimmer_bt_uart.c +++ b/Comms/shimmer_bt_uart.c @@ -797,7 +797,6 @@ void ShimBt_processCmd(void) case GET_GYRO_RANGE_COMMAND: case GET_BMP180_CALIBRATION_COEFFICIENTS_COMMAND: case GET_BMP280_CALIBRATION_COEFFICIENTS_COMMAND: - case GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND: case GET_GYRO_SAMPLING_RATE_COMMAND: case GET_ALT_ACCEL_RANGE_COMMAND: case GET_PRESSURE_OVERSAMPLING_RATIO_COMMAND: @@ -831,6 +830,22 @@ void ShimBt_processCmd(void) getCmdWaitingResponse = gAction; break; } + case GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND: + { +#if defined(SHIMMER3R) + if (isBmp581InUse()) + { + /* The BMP581 outputs pre-compensated pressure/temperature and so + * has no calibration coefficients. In keeping with the approach + * taken for the BMP180/BMP280 calibration commands, a NACK is sent + * back instead. */ + sendNack = 1; + break; + } +#endif + getCmdWaitingResponse = gAction; + break; + } case TEST_CONNECTION_COMMAND: { break; @@ -1894,7 +1909,11 @@ void ShimBt_sendRsp(void) *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; } #elif defined(SHIMMER3R) - *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; + /* Note: not expected to be reached for the BMP581 as a NACK is sent + * back during command processing (the BMP581 has no calibration + * coefficients) */ + *(resPacket + packet_length++) + = isBmp581InUse() ? PRESSURE_SENSOR_BMP581 : PRESSURE_SENSOR_BMP390; #endif memcpy(resPacket + packet_length, get_bmp_calib_data_bytes(), bmpCalibByteLen); packet_length += bmpCalibByteLen; diff --git a/Comms/shimmer_bt_uart.h b/Comms/shimmer_bt_uart.h index ded6f4cc..bb9e0c08 100644 --- a/Comms/shimmer_bt_uart.h +++ b/Comms/shimmer_bt_uart.h @@ -243,7 +243,8 @@ enum { PRESSURE_SENSOR_BMP180 = 0, PRESSURE_SENSOR_BMP280 = 1, - PRESSURE_SENSOR_BMP390 = 2 + PRESSURE_SENSOR_BMP390 = 2, + PRESSURE_SENSOR_BMP581 = 3 }; enum diff --git a/Configuration/shimmer_config.c b/Configuration/shimmer_config.c index e6996422..3a69f852 100644 --- a/Configuration/shimmer_config.c +++ b/Configuration/shimmer_config.c @@ -378,7 +378,11 @@ void ShimConfig_configBytePressureOversamplingRatioSet(uint8_t value) #if defined(SHIMMER3) value = (value <= BMPX80_OSS_8) ? (value & 0x03) : BMPX80_OSS_1; #elif defined(SHIMMER3R) - value = (value <= BMP3_OVERSAMPLING_32X) ? value : BMP3_NO_OVERSAMPLING; + /* The BMP581 supports two additional oversampling settings (64x and 128x) + * over the BMP390's maximum of 32x */ + uint8_t maxOversamplingRatio + = isBmp581InUse() ? BMP5_OVERSAMPLING_128X : BMP3_OVERSAMPLING_32X; + value = (value <= maxOversamplingRatio) ? value : BMP3_NO_OVERSAMPLING; #endif storedConfig.pressureOversamplingRatioLsb = value & 0x03; #if defined(SHIMMER3R) diff --git a/SDCard/shimmer_sd_cfg_file.c b/SDCard/shimmer_sd_cfg_file.c index 4f8ed2e0..d3e3e930 100644 --- a/SDCard/shimmer_sd_cfg_file.c +++ b/SDCard/shimmer_sd_cfg_file.c @@ -75,6 +75,7 @@ static int cfg_key_is(const char *line, const char *key) #define CFG_KEY_PRES_BMP180_PREC "pres_bmp180_prec" #define CFG_KEY_PRES_BMP280_PREC "pres_bmp280_prec" #define CFG_KEY_PRES_BMP390_PREC "pres_bmp390_prec" +#define CFG_KEY_PRES_BMP581_PREC "pres_bmp581_prec" #define CFG_KEY_GSR_RANGE "gsr_range" #define CFG_KEY_EXP_POWER "exp_power" #define CFG_KEY_GYRO_RANGE "gyro_range" @@ -252,7 +253,8 @@ void ShimSdCfgFile_generate(void) (isBmp180InUse() ? CFG_KEY_PRES_BMP180_PREC : CFG_KEY_PRES_BMP280_PREC), ShimConfig_configBytePressureOversamplingRatioGet()); #elif defined(SHIMMER3R) - CFG_WRITE_INT(&cfgFile, CFG_KEY_PRES_BMP390_PREC, + CFG_WRITE_INT(&cfgFile, + (isBmp581InUse() ? CFG_KEY_PRES_BMP581_PREC : CFG_KEY_PRES_BMP390_PREC), ShimConfig_configBytePressureOversamplingRatioGet()); #endif CFG_WRITE_INT(&cfgFile, CFG_KEY_GSR_RANGE, storedConfig->gsrRange); @@ -638,7 +640,8 @@ void ShimSdCfgFile_parse(void) ShimConfig_configBytePressureOversamplingRatioSet(atoi(equals)); } #elif defined(SHIMMER3R) - else if (cfg_key_is(buffer, CFG_KEY_PRES_BMP390_PREC)) + else if (cfg_key_is(buffer, CFG_KEY_PRES_BMP390_PREC) + || cfg_key_is(buffer, CFG_KEY_PRES_BMP581_PREC)) { ShimConfig_configBytePressureOversamplingRatioSet(atoi(equals)); } diff --git a/SDCard/shimmer_sd_header.c b/SDCard/shimmer_sd_header.c index 51b38e80..ad373009 100644 --- a/SDCard/shimmer_sd_header.c +++ b/SDCard/shimmer_sd_header.c @@ -206,7 +206,12 @@ void ShimSdHead_saveBmpCalibrationToSdHeader(void) bmpCalibPtr + BMP180_CALIB_DATA_SIZE, BMP280_CALIB_XTRA_BYTES); } #elif defined(SHIMMER3R) - /* BMP390 had 21 bytes stored in index SDH_TEMP_PRES_CALIBRATION */ - memcpy(&sdHeadText[SDH_TEMP_PRES_CALIBRATION], bmpCalibPtr, BMP3_LEN_CALIB_DATA); + /* BMP390 had 21 bytes stored in index SDH_TEMP_PRES_CALIBRATION. The BMP581 + * outputs pre-compensated pressure/temperature and so has no calibration + * coefficients to store. */ + if (!isBmp581InUse()) + { + memcpy(&sdHeadText[SDH_TEMP_PRES_CALIBRATION], bmpCalibPtr, BMP3_LEN_CALIB_DATA); + } #endif } From 826f9b1b56b9e44c0e4a89a9d45565dd33b00304 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:22:23 +0000 Subject: [PATCH 02/15] Committing clang-format changes --- Configuration/shimmer_config.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Configuration/shimmer_config.c b/Configuration/shimmer_config.c index 3a69f852..0d847cea 100644 --- a/Configuration/shimmer_config.c +++ b/Configuration/shimmer_config.c @@ -380,8 +380,7 @@ void ShimConfig_configBytePressureOversamplingRatioSet(uint8_t value) #elif defined(SHIMMER3R) /* The BMP581 supports two additional oversampling settings (64x and 128x) * over the BMP390's maximum of 32x */ - uint8_t maxOversamplingRatio - = isBmp581InUse() ? BMP5_OVERSAMPLING_128X : BMP3_OVERSAMPLING_32X; + uint8_t maxOversamplingRatio = isBmp581InUse() ? BMP5_OVERSAMPLING_128X : BMP3_OVERSAMPLING_32X; value = (value <= maxOversamplingRatio) ? value : BMP3_NO_OVERSAMPLING; #endif storedConfig.pressureOversamplingRatioLsb = value & 0x03; From 69e1ac29fbc2c48de66f5f747b869427d264fff0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:07:49 +0000 Subject: [PATCH 03/15] Fix ShimBrd_isBoardSrNumberGte: use correct two-part version comparison --- Boards/shimmer_boards.c | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Boards/shimmer_boards.c b/Boards/shimmer_boards.c index c2799198..d1c560f5 100644 --- a/Boards/shimmer_boards.c +++ b/Boards/shimmer_boards.c @@ -318,10 +318,14 @@ uint8_t ShimBrd_isBoardSrNumber(uint8_t exp_brd_id, uint8_t exp_brd_major, uint8 uint8_t ShimBrd_isBoardSrNumberGte(uint8_t exp_brd_id, uint8_t exp_brd_major, uint8_t exp_brd_minor) { - return (ShimBrd_isDaughterCardIdSet() - && (daughterCardIdPage.expansion_brd.exp_brd_id == exp_brd_id - && daughterCardIdPage.expansion_brd.exp_brd_major >= exp_brd_major - && daughterCardIdPage.expansion_brd.exp_brd_minor >= exp_brd_minor)); + if (!ShimBrd_isDaughterCardIdSet() || daughterCardIdPage.expansion_brd.exp_brd_id != exp_brd_id) + { + return 0; + } + uint8_t brd_major = daughterCardIdPage.expansion_brd.exp_brd_major; + uint8_t brd_minor = daughterCardIdPage.expansion_brd.exp_brd_minor; + return (brd_major > exp_brd_major) + || (brd_major == exp_brd_major && brd_minor >= exp_brd_minor); } uint8_t ShimBrd_isBmp581PresentPerSrNumber(void) From f8dc3af44d896c0fec65417d8b018d850196f05f Mon Sep 17 00:00:00 2001 From: s-varna Date: Thu, 9 Jul 2026 17:40:55 +0100 Subject: [PATCH 04/15] DEV-818 TEMP: don't NACK GET_PRESSURE_CALIBRATION_COEFFICIENTS on BMP581 Current Consensys aborts its connect-time handshake when it receives a NACK on GET_PRESSURE_CALIBRATION_COEFFICIENTS, so a BMP581-fitted unit cannot connect. Until the host handles the NACK / the BMP581 sensor id, respond normally and report PRESSURE_SENSOR_BMP390 so the connection completes. Marked with TODO DEV-818 to restore the intended BMP581 NACK once the host side is ready. Co-Authored-By: Claude Opus 4.8 --- Comms/shimmer_bt_uart.c | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/Comms/shimmer_bt_uart.c b/Comms/shimmer_bt_uart.c index 76d8b5ff..e0d5f136 100644 --- a/Comms/shimmer_bt_uart.c +++ b/Comms/shimmer_bt_uart.c @@ -832,17 +832,12 @@ void ShimBt_processCmd(void) } case GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND: { -#if defined(SHIMMER3R) - if (isBmp581InUse()) - { - /* The BMP581 outputs pre-compensated pressure/temperature and so - * has no calibration coefficients. In keeping with the approach - * taken for the BMP180/BMP280 calibration commands, a NACK is sent - * back instead. */ - sendNack = 1; - break; - } -#endif + /* TODO DEV-818: when a BMP581 is fitted this should NACK (the BMP581 + * self-compensates and has no calibration coefficients, per the + * BMP180/BMP280 approach). That NACK is temporarily disabled because + * current Consensys aborts the connection when it receives it during + * its connect-time handshake. Re-enable the BMP581 NACK once the host + * (Consensys) handles it / recognises the BMP581 sensor id. */ getCmdWaitingResponse = gAction; break; } @@ -1909,11 +1904,11 @@ void ShimBt_sendRsp(void) *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; } #elif defined(SHIMMER3R) - /* Note: not expected to be reached for the BMP581 as a NACK is sent - * back during command processing (the BMP581 has no calibration - * coefficients) */ - *(resPacket + packet_length++) - = isBmp581InUse() ? PRESSURE_SENSOR_BMP581 : PRESSURE_SENSOR_BMP390; + /* TODO DEV-818: report PRESSURE_SENSOR_BMP581 (and NACK this command) + * once the host handles the BMP581. For now report BMP390 so current + * Consensys accepts the response and completes its connect handshake - + * paired with the temporarily-disabled NACK in ShimBt_processCmd(). */ + *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; #endif memcpy(resPacket + packet_length, get_bmp_calib_data_bytes(), bmpCalibByteLen); packet_length += bmpCalibByteLen; From 500b6782be77b744d7761ffbeeeaf3f0238f8df5 Mon Sep 17 00:00:00 2001 From: s-varna Date: Wed, 15 Jul 2026 16:12:36 +0100 Subject: [PATCH 05/15] DEV-818 NACK pressure-calib command for BMP581; add bmp581.py BT test The BMP581 self-compensates and has no calibration coefficients, so GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND now NACKs when a BMP581 is fitted (BMP390 path unchanged). The host must treat the NACK as "pre-compensated - convert raw pressure/temperature directly". Note: current Consensys aborts its connect handshake on this NACK; this is intended for NACK-aware hosts/test scripts. Add bmp581.py: streams BMP581 pressure/temperature over Bluetooth, expects the NACK, and converts straight from raw values (Pa = raw/64, degC = signed(raw)/65536). Accepts a COM port or a MAC (resolves MAC -> outgoing COM port) with an interactive port picker. Co-Authored-By: Claude Opus 4.8 --- Comms/shimmer_bt_uart.c | 25 +- .../Bluetooth commands/bmp581.py | 300 ++++++++++++++++++ 2 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 Extras/python_scripts/Bluetooth commands/bmp581.py diff --git a/Comms/shimmer_bt_uart.c b/Comms/shimmer_bt_uart.c index e0d5f136..24210365 100644 --- a/Comms/shimmer_bt_uart.c +++ b/Comms/shimmer_bt_uart.c @@ -832,13 +832,26 @@ void ShimBt_processCmd(void) } case GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND: { - /* TODO DEV-818: when a BMP581 is fitted this should NACK (the BMP581 - * self-compensates and has no calibration coefficients, per the - * BMP180/BMP280 approach). That NACK is temporarily disabled because - * current Consensys aborts the connection when it receives it during - * its connect-time handshake. Re-enable the BMP581 NACK once the host - * (Consensys) handles it / recognises the BMP581 sensor id. */ +#if defined(SHIMMER3R) + /* DEV-818: the BMP581 self-compensates and has NO calibration + * coefficients, so NACK this command when a BMP581 is fitted (mirrors + * the "no coefficients" nature of the part). The host must treat the + * NACK as "sensor is pre-compensated - convert raw pressure/temperature + * directly". Only the BMP390 path returns coefficients. + * WARNING: current Consensys aborts its connect-time handshake on this + * NACK; re-enabling it here is intended for custom hosts/test scripts + * (e.g. bmp581.py) that handle the NACK. */ + if (isBmp581InUse()) + { + sendNack = 1; + } + else + { + getCmdWaitingResponse = gAction; + } +#else getCmdWaitingResponse = gAction; +#endif break; } case TEST_CONNECTION_COMMAND: diff --git a/Extras/python_scripts/Bluetooth commands/bmp581.py b/Extras/python_scripts/Bluetooth commands/bmp581.py new file mode 100644 index 00000000..c970dd45 --- /dev/null +++ b/Extras/python_scripts/Bluetooth commands/bmp581.py @@ -0,0 +1,300 @@ +#!/usr/bin/python +# encoding=utf-8 +# +# BMP581 Bluetooth streaming test (Shimmer3R). +# +# You can pass EITHER a serial port OR the Shimmer's Bluetooth MAC address - the +# script resolves a MAC to its outgoing COM port automatically: +# bmp581.py COM28 # explicit port +# bmp581.py 00:06:66:B1:4B:B7 # MAC (colons optional) +# bmp581.py 000666B14BB7 # MAC, no separators +# bmp581.py --list # list all BT serial ports + their MACs +# Or set DEFAULT_MAC below and run with no argument. +# +# Flow: +# 1. Send GET_PRESSURE_CALIBRATION_COEFFICIENTS (0xA7). A BMP581 self-compensates +# and has NO calibration coefficients, so the firmware replies with a NACK +# (0xFE). This script expects that NACK and proceeds - no coefficients needed. +# 2. Enable pressure + temperature, set the sampling rate, start streaming. +# 3. Convert each sample straight from the RAW 24-bit values (no host trimming): +# pressure (uint24 little-endian) Pa = raw / 64 +# temperature (int24 little-endian) degC = raw / 65536 +# +# Packet layout while only pressure+temperature are enabled (framesize 10): +# [1] packet type | [3] timestamp | [3] pressure | [3] temperature (all little-endian) + +import re +import serial +import serial.tools.list_ports +import struct +import sys + +# --- User setting: optionally hard-code your Shimmer's MAC here ------------- +DEFAULT_MAC = "" # e.g. "00:06:66:B1:4B:B7" (leave "" to require a CLI argument) + +# --- Shimmer BT bytes ------------------------------------------------------ +ACK = 0xFF +NACK = 0xFE +GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND = 0xA7 +PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE = 0xA6 +SET_SENSORS_COMMAND = 0x08 +SET_SAMPLING_RATE_COMMAND = 0x05 +START_STREAMING_COMMAND = 0x07 +STOP_STREAMING_COMMAND = 0x20 + +# chEnPressureAndTemperature is bit 2 of the "Sensors 2" byte (3rd sensor byte) +SENSORS0 = 0x00 +SENSORS1 = 0x00 +SENSORS2 = 0x04 # pressure & temperature + +DESIRED_SAMPLING_FREQ_HZ = 10.0 + +# BMP581 sanity ranges (quick pass/fail hint) +PRESS_MIN_PA = 30000.0 +PRESS_MAX_PA = 125000.0 +TEMP_MIN_C = -40.0 +TEMP_MAX_C = 85.0 + + +# --- Port / MAC helpers ---------------------------------------------------- +def normalize_mac(s): + """Strip separators, upper-case -> 12 hex chars (or whatever hex it holds).""" + return re.sub(r'[^0-9A-Fa-f]', '', s).upper() + + +def extract_mac_from_hwid(hwid): + """Pull the device MAC (a contiguous 12-hex run) out of a Windows BT hwid. + Strips the {..} service-UUID first so the Bluetooth base UUID fragment + (00805F9B34FB) on non-connectable local ports isn't mistaken for a MAC.""" + h = re.sub(r'\{[^}]*\}', '', hwid or "").replace(':', '') + BASE = '00805F9B34FB' # Bluetooth base UUID tail - not a device MAC + candidates = [c.upper() for c in re.findall(r'([0-9A-Fa-f]{12})', h) + if set(c.upper()) != {'0'} and c.upper() != BASE] + return candidates[-1] if candidates else None + + +def serial_ports_bluetooth(): + """Ports whose description mentions Bluetooth (same filter as + shimmer_device.serial_ports_bluetooth()).""" + return [p for p in serial.tools.list_ports.comports() + if "Bluetooth" in (p.description or "")] + + +def list_bt_ports(): + print("Bluetooth serial ports (MAC | port | description):") + found = False + for p in serial.tools.list_ports.comports(): + mac = extract_mac_from_hwid(p.hwid) + if mac: # only ports carrying a real device MAC are connectable + found = True + print(" %-12s %-6s %s" % (mac, p.device, p.description)) + if not found: + print(" (none found - is the device paired?)") + + +def pick_com_port_interactive(): + """Mirror of shimmer_app_common.get_selected_com_port(dock_ports=False): + list the Bluetooth serial ports and let the user pick one by number.""" + options = serial_ports_bluetooth() + if not options: + print("No Bluetooth serial ports found - is the Shimmer paired?") + return None + print("Pick an option:") + for index, item in enumerate(options): + mac = extract_mac_from_hwid(item.hwid) or "?" + print(" %d) %s (MAC %s) %s" % (index + 1, item.device, mac, item.description)) + user_input = "" + while (not user_input.isdigit()) or int(user_input) < 1 or int(user_input) > len(options): + user_input = input("Your choice: ").strip() + port = options[int(user_input) - 1].device + print("You picked: %s\n" % port) + return port + + +def find_port_by_mac(mac): + """Return the COM port whose hwid contains this MAC, preferring the + outgoing port (hwid ..._C00000000).""" + target = normalize_mac(mac) + outgoing = None + fallback = None + for p in serial.tools.list_ports.comports(): + hwid = (p.hwid or "").upper().replace(':', '').replace('-', '') + if target and target in hwid: + if '_C00000000' in hwid: + outgoing = p.device + elif fallback is None: + fallback = p.device + return outgoing or fallback + + +def resolve_port(arg): + """arg may be a COM/dev path or a MAC address. Returns a port string or None.""" + if not arg: + arg = DEFAULT_MAC + if not arg: + return None + a = arg.strip() + if a.upper().startswith('COM') or a.startswith('/dev/'): + return a + n = normalize_mac(a) + if len(n) == 12: + port = find_port_by_mac(n) + if port: + print("MAC %s -> %s" % (a, port)) + else: + print("No paired serial port found for MAC %s." % a) + list_bt_ports() + return port + # not COM, not a 12-hex MAC: use verbatim and let serial.Serial complain + return a + + +def wait_for_ack(): + ack = struct.pack('B', ACK) + ddata = bytes() + while ddata != ack: + ddata = ser.read(1) + return + + +def u24_le(b0, b1, b2): + """Unsigned 24-bit, little-endian (b0 = XLSB).""" + return b0 | (b1 << 8) | (b2 << 16) + + +def s24_le(b0, b1, b2): + """Signed 24-bit, little-endian (b0 = XLSB).""" + v = u24_le(b0, b1, b2) + if v >= 0x800000: + v -= 0x1000000 + return v + + +def check_calibration_nack(): + """Send GET_PRESSURE_CALIBRATION_COEFFICIENTS and expect a NACK for a BMP581.""" + ser.write(struct.pack('B', GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND)) + resp = ser.read(1) + if len(resp) == 0: + print("WARNING: no reply to calibration command (timeout).") + return False + b = resp[0] + if b == NACK: + print("calibration command NACK'd (0xFE) - BMP581 is pre-compensated, " + "no coefficients needed. Good.") + return True + if b == ACK: + hdr = ser.read(1) + if len(hdr) and hdr[0] == PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE: + length = ser.read(1)[0] + payload = ser.read(length) + sensor_id = payload[0] if len(payload) else -1 + print("WARNING: device ACK'd and returned coefficients (sensor id " + "0x%02x). NACK not active for this unit - discarding " + "coefficients and streaming raw anyway." % sensor_id) + else: + print("WARNING: unexpected response header after ACK: %r" % hdr) + return False + print("WARNING: unexpected reply 0x%02x to calibration command." % b) + return False + + +# --- Argument handling ----------------------------------------------------- +raw_args = sys.argv[1:] + +if any(a in ('--list', '-l', 'list') for a in raw_args): + list_bt_ports() + sys.exit(0) + +# Join args and drop stray spaces so "COM 38" -> "COM38" and a spaced MAC works too +arg = "".join(raw_args).strip() if raw_args else None + +port = resolve_port(arg) +if not port: + # No argument (or MAC not found): fall back to the interactive numbered + # picker, same behaviour as test_bt_cmds.py / get_selected_com_port(). + port = pick_com_port_interactive() +if not port: + print("No device selected. You can also pass a COM port or MAC directly:") + print(" bmp581.py COM28") + print(" bmp581.py 00:06:66:B1:4B:B7") + print(" bmp581.py --list (show paired BT ports + MACs)") + sys.exit(1) + +ser = serial.Serial(port, 115200, timeout=2) +ser.flushInput() +print("port %s opened." % port) + +# 1. calibration handshake - expect NACK for a BMP581 +check_calibration_nack() + +# 2. enable pressure + temperature +ser.write(struct.pack('BBBB', SET_SENSORS_COMMAND, SENSORS0, SENSORS1, SENSORS2)) +wait_for_ack() +print("sensors set (pressure + temperature).") + +# 3. set sampling rate: clock_wait = 32768 / freq, little-endian uint16 +clock_wait = int(32768 / DESIRED_SAMPLING_FREQ_HZ) +ser.write(struct.pack(' 0 + else "CHECK - some/all samples out of range.")) + else: + print("RESULT: FAIL - no samples received.") From 0c8c3a1bc94ba3b3d445df2c85022dbf59da5088 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:21:32 +0000 Subject: [PATCH 06/15] Fix stale TODO DEV-818 comment and add PRESSURE_SENSOR_BMP581 branch in response builder --- Comms/shimmer_bt_uart.c | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Comms/shimmer_bt_uart.c b/Comms/shimmer_bt_uart.c index 24210365..a528e6d5 100644 --- a/Comms/shimmer_bt_uart.c +++ b/Comms/shimmer_bt_uart.c @@ -1917,11 +1917,10 @@ void ShimBt_sendRsp(void) *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; } #elif defined(SHIMMER3R) - /* TODO DEV-818: report PRESSURE_SENSOR_BMP581 (and NACK this command) - * once the host handles the BMP581. For now report BMP390 so current - * Consensys accepts the response and completes its connect handshake - - * paired with the temporarily-disabled NACK in ShimBt_processCmd(). */ - *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; + /* BMP581 NACKs this command in ShimBt_processCmd() - this path is only + * reached for BMP390. */ + *(resPacket + packet_length++) + = isBmp581InUse() ? PRESSURE_SENSOR_BMP581 : PRESSURE_SENSOR_BMP390; #endif memcpy(resPacket + packet_length, get_bmp_calib_data_bytes(), bmpCalibByteLen); packet_length += bmpCalibByteLen; From 64234069e413b580ab7eeec692d9495f6aed2a1d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:22:57 +0000 Subject: [PATCH 07/15] Remove redundant isBmp581InUse check in response builder; BMP581 always NACKs this path --- Comms/shimmer_bt_uart.c | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Comms/shimmer_bt_uart.c b/Comms/shimmer_bt_uart.c index a528e6d5..1d4f881f 100644 --- a/Comms/shimmer_bt_uart.c +++ b/Comms/shimmer_bt_uart.c @@ -1917,10 +1917,9 @@ void ShimBt_sendRsp(void) *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; } #elif defined(SHIMMER3R) - /* BMP581 NACKs this command in ShimBt_processCmd() - this path is only - * reached for BMP390. */ - *(resPacket + packet_length++) - = isBmp581InUse() ? PRESSURE_SENSOR_BMP581 : PRESSURE_SENSOR_BMP390; + /* BMP581 NACKs this command in ShimBt_processCmd() so this path is + * only reached when a BMP390 is fitted. */ + *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; #endif memcpy(resPacket + packet_length, get_bmp_calib_data_bytes(), bmpCalibByteLen); packet_length += bmpCalibByteLen; From 4fdd7379c8fad7467e372d1a10aa000428ed290b Mon Sep 17 00:00:00 2001 From: s-varna Date: Thu, 16 Jul 2026 13:23:38 +0100 Subject: [PATCH 08/15] DEV-818 bmp581.py: PEP 263 encoding cookie + guard length-byte read (PR review) - Use the canonical `# -*- coding: utf-8 -*-` cookie so the UTF-8 declaration is unambiguously recognised. - Guard the calibration-response length-byte read against an empty (timed-out) read so a transient link/timeout prints a warning and returns cleanly instead of raising IndexError. Co-Authored-By: Claude Opus 4.8 --- Extras/python_scripts/Bluetooth commands/bmp581.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Extras/python_scripts/Bluetooth commands/bmp581.py b/Extras/python_scripts/Bluetooth commands/bmp581.py index c970dd45..4057fef5 100644 --- a/Extras/python_scripts/Bluetooth commands/bmp581.py +++ b/Extras/python_scripts/Bluetooth commands/bmp581.py @@ -1,5 +1,5 @@ #!/usr/bin/python -# encoding=utf-8 +# -*- coding: utf-8 -*- # # BMP581 Bluetooth streaming test (Shimmer3R). # @@ -185,7 +185,11 @@ def check_calibration_nack(): if b == ACK: hdr = ser.read(1) if len(hdr) and hdr[0] == PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE: - length = ser.read(1)[0] + lenb = ser.read(1) + if len(lenb) == 0: + print("WARNING: timed out reading calibration length byte.") + return False + length = lenb[0] payload = ser.read(length) sensor_id = payload[0] if len(payload) else -1 print("WARNING: device ACK'd and returned coefficients (sensor id " From 718a2d41c00353db578344037bd80aea266a4de9 Mon Sep 17 00:00:00 2001 From: s-varna Date: Thu, 16 Jul 2026 13:39:28 +0100 Subject: [PATCH 09/15] DEV-818 Address PR review (Mark): move BMP581 calib NACK to sendRsp; GSR 7.2 - GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND: move the BMP581 NACK out of ShimBt_processCmd and into ShimBt_sendRsp, alongside the BMP180/BMP280 handling, per review. processCmd now just routes the command; the BMP581 case in sendRsp emits a clean NACK (resets packet_length and writes only the NACK byte, since the ACK/NACK bytes are written before the response switch). - ShimBrd_isBmp581PresentPerSrNumber(): lower EXP_BRD_GSR_UNIFIED threshold from 8.2 to 7.2 per review (GSR-unified boards carry the BMP581 from 7.2). Co-Authored-By: Claude Opus 4.8 --- Boards/shimmer_boards.c | 2 +- Comms/shimmer_bt_uart.c | 40 +++++++++++++++++++--------------------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/Boards/shimmer_boards.c b/Boards/shimmer_boards.c index d1c560f5..9c832aac 100644 --- a/Boards/shimmer_boards.c +++ b/Boards/shimmer_boards.c @@ -335,7 +335,7 @@ uint8_t ShimBrd_isBmp581PresentPerSrNumber(void) return (ShimBrd_isHwId(HW_ID_SHIMMER3R) && (ShimBrd_isBoardSrNumberGte(SHIMMER3_IMU, 11, 2) || ShimBrd_isBoardSrNumberGte(EXP_BRD_EXG_UNIFIED, 8, 2) - || ShimBrd_isBoardSrNumberGte(EXP_BRD_GSR_UNIFIED, 8, 2) + || ShimBrd_isBoardSrNumberGte(EXP_BRD_GSR_UNIFIED, 7, 2) || ShimBrd_isBoardSrNumberGte(EXP_BRD_BR_AMP_UNIFIED, 4, 2))); } diff --git a/Comms/shimmer_bt_uart.c b/Comms/shimmer_bt_uart.c index 1d4f881f..2f595962 100644 --- a/Comms/shimmer_bt_uart.c +++ b/Comms/shimmer_bt_uart.c @@ -832,26 +832,10 @@ void ShimBt_processCmd(void) } case GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND: { -#if defined(SHIMMER3R) - /* DEV-818: the BMP581 self-compensates and has NO calibration - * coefficients, so NACK this command when a BMP581 is fitted (mirrors - * the "no coefficients" nature of the part). The host must treat the - * NACK as "sensor is pre-compensated - convert raw pressure/temperature - * directly". Only the BMP390 path returns coefficients. - * WARNING: current Consensys aborts its connect-time handshake on this - * NACK; re-enabling it here is intended for custom hosts/test scripts - * (e.g. bmp581.py) that handle the NACK. */ - if (isBmp581InUse()) - { - sendNack = 1; - } - else - { - getCmdWaitingResponse = gAction; - } -#else + /* Handled in ShimBt_sendRsp (as for GET_BMP180/BMP280_CALIBRATION_ + * COEFFICIENTS_COMMAND): a BMP581 self-compensates and has no + * coefficients, so that path NACKs it; a BMP390 returns coefficients. */ getCmdWaitingResponse = gAction; -#endif break; } case TEST_CONNECTION_COMMAND: @@ -1900,6 +1884,21 @@ void ShimBt_sendRsp(void) } case GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND: { +#if defined(SHIMMER3R) + if (isBmp581InUse()) + { + /* The BMP581 self-compensates and has no calibration coefficients, so + * NACK this command. Discard the ACK already staged (reset + * packet_length) and emit only the NACK, matching a normal NACK's + * on-wire form. The bare sendNack flag can't be used here: the + * ACK/NACK bytes are written before this switch, so setting it now + * would send ACK-only and leave sendNack lingering into the next + * command. */ + packet_length = 0; + *(resPacket + packet_length++) = NACK_COMMAND_PROCESSED; + break; + } +#endif bmpCalibByteLen = get_bmp_calib_data_bytes_len(); *(resPacket + packet_length++) = PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE; *(resPacket + packet_length++) = 1U + bmpCalibByteLen; @@ -1917,8 +1916,7 @@ void ShimBt_sendRsp(void) *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; } #elif defined(SHIMMER3R) - /* BMP581 NACKs this command in ShimBt_processCmd() so this path is - * only reached when a BMP390 is fitted. */ + /* Only reached when a BMP390 is fitted (BMP581 NACKs above). */ *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; #endif memcpy(resPacket + packet_length, get_bmp_calib_data_bytes(), bmpCalibByteLen); From 94eae9987039370414d809cb7beac72797044527 Mon Sep 17 00:00:00 2001 From: s-varna Date: Thu, 16 Jul 2026 13:43:33 +0100 Subject: [PATCH 10/15] DEV-818 Match BMP581 board list to Jira spec (PR review) Mirror the DEV-818 board list 1:1 in ShimBrd_isBmp581PresentPerSrNumber(): SR31-11-2, SR47-8-2, SR48-7-2, SR48-8-2, SR49-4-2. SR48 (GSR unified) is listed for both the 7.x and 8.x lines, so keep both GSR checks (7.2 added per review alongside the existing 8.2). Co-Authored-By: Claude Opus 4.8 --- Boards/shimmer_boards.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Boards/shimmer_boards.c b/Boards/shimmer_boards.c index 9c832aac..4a987ff0 100644 --- a/Boards/shimmer_boards.c +++ b/Boards/shimmer_boards.c @@ -330,12 +330,15 @@ uint8_t ShimBrd_isBoardSrNumberGte(uint8_t exp_brd_id, uint8_t exp_brd_major, ui uint8_t ShimBrd_isBmp581PresentPerSrNumber(void) { - /* The BMP581 replaces the BMP390 on up-rev'd Shimmer3R boards (DEV-818). - * Note the >= applies to both the major and minor revs. */ + /* The BMP581 replaces the BMP390 on up-rev'd Shimmer3R boards. Per DEV-818 + * the fitted models are (>= applies to both rev fields): + * SR31-11-2, SR47-8-2, SR48-7-2, SR48-8-2, SR49-4-2 + * SR48 (GSR unified) is listed for both the 7.x and 8.x lines. */ return (ShimBrd_isHwId(HW_ID_SHIMMER3R) && (ShimBrd_isBoardSrNumberGte(SHIMMER3_IMU, 11, 2) || ShimBrd_isBoardSrNumberGte(EXP_BRD_EXG_UNIFIED, 8, 2) || ShimBrd_isBoardSrNumberGte(EXP_BRD_GSR_UNIFIED, 7, 2) + || ShimBrd_isBoardSrNumberGte(EXP_BRD_GSR_UNIFIED, 8, 2) || ShimBrd_isBoardSrNumberGte(EXP_BRD_BR_AMP_UNIFIED, 4, 2))); } From 58ac87e6c728612619f8525179572ced0f365d27 Mon Sep 17 00:00:00 2001 From: s-varna Date: Thu, 16 Jul 2026 14:22:44 +0100 Subject: [PATCH 11/15] DEV-818 Fix BMP581 SR48 detection: exclude SR48-8-0/8-1 (PR review) The SR48 rev-7 check was >= 7.2, which also matched SR48-8-0 and SR48-8-1 (major > 7) - boards that do NOT carry the BMP581. Bound the rev-7 case to major == 7 && minor >= 2 (>= 7.2 AND < 8.0); rev 8 stays covered by the separate >= 8.2 check. Co-Authored-By: Claude Opus 4.8 --- Boards/shimmer_boards.c | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Boards/shimmer_boards.c b/Boards/shimmer_boards.c index 4a987ff0..7cc74999 100644 --- a/Boards/shimmer_boards.c +++ b/Boards/shimmer_boards.c @@ -333,11 +333,15 @@ uint8_t ShimBrd_isBmp581PresentPerSrNumber(void) /* The BMP581 replaces the BMP390 on up-rev'd Shimmer3R boards. Per DEV-818 * the fitted models are (>= applies to both rev fields): * SR31-11-2, SR47-8-2, SR48-7-2, SR48-8-2, SR49-4-2 - * SR48 (GSR unified) is listed for both the 7.x and 8.x lines. */ + * SR48 (GSR unified) has two lines: rev 7 from .2 AND rev 8 from .2. The + * rev-7 case must be "major == 7 && minor >= 2" (expressed as >= 7.2 AND + * < 8.0) - a plain >= 7.2 would wrongly include SR48-8-0 / SR48-8-1, which do + * not carry the BMP581. Rev 8 is covered by the separate >= 8.2 check. */ return (ShimBrd_isHwId(HW_ID_SHIMMER3R) && (ShimBrd_isBoardSrNumberGte(SHIMMER3_IMU, 11, 2) || ShimBrd_isBoardSrNumberGte(EXP_BRD_EXG_UNIFIED, 8, 2) - || ShimBrd_isBoardSrNumberGte(EXP_BRD_GSR_UNIFIED, 7, 2) + || (ShimBrd_isBoardSrNumberGte(EXP_BRD_GSR_UNIFIED, 7, 2) + && !ShimBrd_isBoardSrNumberGte(EXP_BRD_GSR_UNIFIED, 8, 0)) || ShimBrd_isBoardSrNumberGte(EXP_BRD_GSR_UNIFIED, 8, 2) || ShimBrd_isBoardSrNumberGte(EXP_BRD_BR_AMP_UNIFIED, 4, 2))); } From 7705c28ebb13aa8f4bb19533e9a91cbadfa76711 Mon Sep 17 00:00:00 2001 From: s-varna Date: Thu, 16 Jul 2026 14:36:09 +0100 Subject: [PATCH 12/15] DEV-818 Keep BMP581 calib NACK in ShimBt_processCmd (clean 0xFE) Revert the relocation into ShimBt_sendRsp. In sendRsp the ACK/NACK byte is written before the response switch with no re-check, so setting sendNack from inside the switch (as the BMP180/BMP280 cases do) yields an ACK with no data and leaves sendNack set for the next command. This command is sent at connect, so it must return a clean NACK - decide it in ShimBt_processCmd where sendAck vs sendNack is actually chosen. Co-Authored-By: Claude Opus 4.8 --- Comms/shimmer_bt_uart.c | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/Comms/shimmer_bt_uart.c b/Comms/shimmer_bt_uart.c index 2f595962..38f29a8a 100644 --- a/Comms/shimmer_bt_uart.c +++ b/Comms/shimmer_bt_uart.c @@ -832,10 +832,25 @@ void ShimBt_processCmd(void) } case GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND: { - /* Handled in ShimBt_sendRsp (as for GET_BMP180/BMP280_CALIBRATION_ - * COEFFICIENTS_COMMAND): a BMP581 self-compensates and has no - * coefficients, so that path NACKs it; a BMP390 returns coefficients. */ +#if defined(SHIMMER3R) + /* The BMP581 self-compensates and has NO calibration coefficients, so + * NACK this command when a BMP581 is fitted; a BMP390 returns its + * coefficients via ShimBt_sendRsp. Handled here (not in ShimBt_sendRsp + * like the BMP180/BMP280 cases) so a clean NACK is emitted: the ACK/NACK + * byte is written before the sendRsp response switch, so setting + * sendNack from inside that switch would send an ACK with no data and + * leave sendNack lingering into the next command. */ + if (isBmp581InUse()) + { + sendNack = 1; + } + else + { + getCmdWaitingResponse = gAction; + } +#else getCmdWaitingResponse = gAction; +#endif break; } case TEST_CONNECTION_COMMAND: @@ -1884,21 +1899,6 @@ void ShimBt_sendRsp(void) } case GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND: { -#if defined(SHIMMER3R) - if (isBmp581InUse()) - { - /* The BMP581 self-compensates and has no calibration coefficients, so - * NACK this command. Discard the ACK already staged (reset - * packet_length) and emit only the NACK, matching a normal NACK's - * on-wire form. The bare sendNack flag can't be used here: the - * ACK/NACK bytes are written before this switch, so setting it now - * would send ACK-only and leave sendNack lingering into the next - * command. */ - packet_length = 0; - *(resPacket + packet_length++) = NACK_COMMAND_PROCESSED; - break; - } -#endif bmpCalibByteLen = get_bmp_calib_data_bytes_len(); *(resPacket + packet_length++) = PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE; *(resPacket + packet_length++) = 1U + bmpCalibByteLen; @@ -1916,7 +1916,8 @@ void ShimBt_sendRsp(void) *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; } #elif defined(SHIMMER3R) - /* Only reached when a BMP390 is fitted (BMP581 NACKs above). */ + /* BMP581 NACKs this command in ShimBt_processCmd() so this path is + * only reached when a BMP390 is fitted. */ *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; #endif memcpy(resPacket + packet_length, get_bmp_calib_data_bytes(), bmpCalibByteLen); From 45492c73931dc74c44c946116056e855308e519a Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:36:25 +0000 Subject: [PATCH 13/15] Committing clang-format changes --- Comms/shimmer_bt_uart.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Comms/shimmer_bt_uart.c b/Comms/shimmer_bt_uart.c index 38f29a8a..9f0e9cf2 100644 --- a/Comms/shimmer_bt_uart.c +++ b/Comms/shimmer_bt_uart.c @@ -836,10 +836,10 @@ void ShimBt_processCmd(void) /* The BMP581 self-compensates and has NO calibration coefficients, so * NACK this command when a BMP581 is fitted; a BMP390 returns its * coefficients via ShimBt_sendRsp. Handled here (not in ShimBt_sendRsp - * like the BMP180/BMP280 cases) so a clean NACK is emitted: the ACK/NACK - * byte is written before the sendRsp response switch, so setting - * sendNack from inside that switch would send an ACK with no data and - * leave sendNack lingering into the next command. */ + * like the BMP180/BMP280 cases) so a clean NACK is emitted: the + * ACK/NACK byte is written before the sendRsp response switch, so + * setting sendNack from inside that switch would send an ACK with no + * data and leave sendNack lingering into the next command. */ if (isBmp581InUse()) { sendNack = 1; From be1b6ab7664c012f42378b368349481c2c6f78af Mon Sep 17 00:00:00 2001 From: s-varna Date: Thu, 16 Jul 2026 15:12:41 +0100 Subject: [PATCH 14/15] DEV-818 Fix sendNack-in-sendRsp so calib NACKs are clean (PR review) ShimBt_sendRsp wrote the ACK/NACK byte before the response switch with no re-check, so a case setting sendNack (GET_BMP180/BMP280 on SHIMMER3R) sent an ACK with no data and left sendNack set for the next command. Add a post-switch fixup: if a case set sendNack, overwrite resPacket[0] with NACK and truncate to one byte -> clean 0xFE. No-op for every existing path (ACK-then-data, processCmd-set NACKs) and for the SHIMMER3 build (no switch case sets sendNack there, so classic BMP180/280 behaviour is unchanged). With the mechanism fixed, handle the BMP581 pressure-calibration NACK in the GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND case in ShimBt_sendRsp (next to BMP180/280) so all three are handled consistently in one place. Co-Authored-By: Claude Opus 4.8 --- Comms/shimmer_bt_uart.c | 46 +++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/Comms/shimmer_bt_uart.c b/Comms/shimmer_bt_uart.c index 9f0e9cf2..d03c7b23 100644 --- a/Comms/shimmer_bt_uart.c +++ b/Comms/shimmer_bt_uart.c @@ -832,25 +832,10 @@ void ShimBt_processCmd(void) } case GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND: { -#if defined(SHIMMER3R) - /* The BMP581 self-compensates and has NO calibration coefficients, so - * NACK this command when a BMP581 is fitted; a BMP390 returns its - * coefficients via ShimBt_sendRsp. Handled here (not in ShimBt_sendRsp - * like the BMP180/BMP280 cases) so a clean NACK is emitted: the - * ACK/NACK byte is written before the sendRsp response switch, so - * setting sendNack from inside that switch would send an ACK with no - * data and leave sendNack lingering into the next command. */ - if (isBmp581InUse()) - { - sendNack = 1; - } - else - { - getCmdWaitingResponse = gAction; - } -#else + /* Handled in ShimBt_sendRsp alongside GET_BMP180/BMP280_CALIBRATION_ + * COEFFICIENTS_COMMAND: a BMP581 self-compensates (no coefficients) and + * NACKs there; a BMP390 returns its coefficients. */ getCmdWaitingResponse = gAction; -#endif break; } case TEST_CONNECTION_COMMAND: @@ -1899,6 +1884,16 @@ void ShimBt_sendRsp(void) } case GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND: { +#if defined(SHIMMER3R) + /* BMP581 self-compensates - no calibration coefficients, so NACK (like + * the BMP180/BMP280 cases above). The switch-set NACK is turned into a + * clean single-byte NACK by the sendNack fixup after this switch. */ + if (isBmp581InUse()) + { + sendNack = 1; + break; + } +#endif bmpCalibByteLen = get_bmp_calib_data_bytes_len(); *(resPacket + packet_length++) = PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE; *(resPacket + packet_length++) = 1U + bmpCalibByteLen; @@ -1916,8 +1911,7 @@ void ShimBt_sendRsp(void) *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; } #elif defined(SHIMMER3R) - /* BMP581 NACKs this command in ShimBt_processCmd() so this path is - * only reached when a BMP390 is fitted. */ + /* Only reached when a BMP390 is fitted (BMP581 NACKs above). */ *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; #endif memcpy(resPacket + packet_length, get_bmp_calib_data_bytes(), bmpCalibByteLen); @@ -2205,6 +2199,18 @@ void ShimBt_sendRsp(void) } getCmdWaitingResponse = 0; + /* A response case above may set sendNack (an unsupported command for the + * fitted hardware - e.g. a pressure-calibration request on a self- + * compensating sensor). The ACK/NACK byte is written before the switch, so + * honour a switch-set NACK here: overwrite the staged ACK with a NACK and + * drop any response bytes so a clean single-byte NACK is sent. */ + if (sendNack) + { + resPacket[0] = NACK_COMMAND_PROCESSED; + packet_length = 1; + sendNack = 0; + } + COMMS_CRC_MODE crcMode = ShimBt_getCrcMode(); if (crcMode != CRC_OFF) { From 2d5ae16caee3759cf6e74bbf6e8c632a638175b1 Mon Sep 17 00:00:00 2001 From: s-varna Date: Wed, 29 Jul 2026 21:04:54 +0100 Subject: [PATCH 15/15] DEV-818 Add BMP581/BMP390 fridge-test tooling (live plots + comparison) bmp581_plot.py: live raw + host temp-corrected pressure (3 panels), CSV log, per-unit quadratic/cubic correction + 2-point temp calibration + offset. bmp390_plot.py: host BMP3-compensated reference with the same acquisition path. bmp_compare.py: combined 5-panel BMP581-vs-BMP390 figure + slope/offset stats. All resilient to BT dropouts (stall watchdog + auto-reconnect). Co-Authored-By: Claude Opus 4.8 --- .../Bluetooth commands/bmp390_plot.py | 522 ++++++++++++++++++ .../Bluetooth commands/bmp581_plot.py | 517 +++++++++++++++++ .../Bluetooth commands/bmp_compare.py | 246 +++++++++ 3 files changed, 1285 insertions(+) create mode 100644 Extras/python_scripts/Bluetooth commands/bmp390_plot.py create mode 100644 Extras/python_scripts/Bluetooth commands/bmp581_plot.py create mode 100644 Extras/python_scripts/Bluetooth commands/bmp_compare.py diff --git a/Extras/python_scripts/Bluetooth commands/bmp390_plot.py b/Extras/python_scripts/Bluetooth commands/bmp390_plot.py new file mode 100644 index 00000000..9c40d578 --- /dev/null +++ b/Extras/python_scripts/Bluetooth commands/bmp390_plot.py @@ -0,0 +1,522 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# BMP390 live PLOT + CSV logger - the BMP390 counterpart of bmp581_plot.py, for +# running a fridge test in parallel and seeing the real difference. +# +# UNLIKE the BMP581 (which self-compensates on-chip and only needs raw/64), the +# BMP390 streams RAW UNCOMPENSATED pressure/temperature plus 21 NVM calibration +# coefficients that the HOST must apply (Bosch BMP3 floating-point compensation, +# BST-BMP390-DS002 sec 8). This script reads those coefficients over BT +# (GET_PRESSURE_CALIBRATION_COEFFICIENTS, 0xA7 -> 0xA6 response) and applies the +# full polynomial, so the plotted pressure/temperature are properly compensated. +# +# ROBUSTNESS: survives Bluetooth dropouts. If the link stalls or errors it closes +# the port, reconnects, and re-issues the streaming commands automatically (up to +# MAX_RECONNECTS times). Every sample is flushed to CSV immediately, so captured +# data is safe even on a hard crash. Calibration is read once and reused across +# reconnects. +# +# Usage (same as bmp581_plot.py): +# bmp390_plot.py COM bmp390_plot.py bmp390_plot.py --list +# +# Requires pyserial; matplotlib is auto-installed on first run if missing. + +import bisect +import os +import re +import struct +import subprocess +import sys +import time + +import serial +import serial.tools.list_ports + + +def _ensure_matplotlib(): + try: + import matplotlib # noqa: F401 + return True + except Exception: + print("matplotlib not found - attempting a one-time install via pip ...") + try: + subprocess.check_call([sys.executable, "-m", "pip", "install", "matplotlib"]) + import matplotlib # noqa: F401 + return True + except Exception as e: + print(" auto-install failed (%s). Run manually: pip install matplotlib" % e) + return False + + +HAVE_MPL = _ensure_matplotlib() +if HAVE_MPL: + import matplotlib + try: + matplotlib.use("TkAgg") + except Exception: + pass + import matplotlib.pyplot as plt +else: + print("Continuing without plots - data still logged to bmp390_stream.csv") + +# --- User settings --------------------------------------------------------- +DEFAULT_MAC = "" +DESIRED_SAMPLING_FREQ_HZ = 51.2 +PLOT_WINDOW_S = 120.0 # rolling x-axis window (fridge runs are minutes long) + +# --- Robustness settings --------------------------------------------------- +SERIAL_TIMEOUT_S = 1.0 # per-read timeout (keeps the GUI responsive) +STALL_TIMEOUT_S = 12.0 # no bytes for this long -> treat link as stalled +RECONNECT_WAIT_S = 3.0 # pause between reconnect attempts +MAX_RECONNECTS = 20 # give up after this many consecutive failures +ACK_TIMEOUT_S = 4.0 # how long to wait for a command ACK + +# --- Shimmer BT bytes ------------------------------------------------------ +ACK = 0xFF +NACK = 0xFE +GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND = 0xA7 +PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE = 0xA6 +SET_SENSORS_COMMAND = 0x08 +SET_SAMPLING_RATE_COMMAND = 0x05 +START_STREAMING_COMMAND = 0x07 +STOP_STREAMING_COMMAND = 0x20 +SENSORS0 = 0x00 +SENSORS1 = 0x00 +SENSORS2 = 0x04 # pressure & temperature + + +# --- Port / MAC helpers (same as bmp581_plot.py) --------------------------- +def normalize_mac(s): + return re.sub(r'[^0-9A-Fa-f]', '', s).upper() + + +def extract_mac_from_hwid(hwid): + h = re.sub(r'\{[^}]*\}', '', hwid or "").replace(':', '') + BASE = '00805F9B34FB' + cands = [c.upper() for c in re.findall(r'([0-9A-Fa-f]{12})', h) + if set(c.upper()) != {'0'} and c.upper() != BASE] + return cands[-1] if cands else None + + +def serial_ports_bluetooth(): + return [p for p in serial.tools.list_ports.comports() + if "Bluetooth" in (p.description or "")] + + +def list_bt_ports(): + print("Bluetooth serial ports (MAC | port | description):") + found = False + for p in serial.tools.list_ports.comports(): + mac = extract_mac_from_hwid(p.hwid) + if mac: + found = True + print(" %-12s %-6s %s" % (mac, p.device, p.description)) + if not found: + print(" (none found - is the device paired?)") + + +def pick_com_port_interactive(): + options = serial_ports_bluetooth() + if not options: + print("No Bluetooth serial ports found - is the Shimmer paired?") + return None + print("Pick an option:") + for index, item in enumerate(options): + mac = extract_mac_from_hwid(item.hwid) or "?" + print(" %d) %s (MAC %s) %s" % (index + 1, item.device, mac, item.description)) + ui = "" + while (not ui.isdigit()) or int(ui) < 1 or int(ui) > len(options): + ui = input("Your choice: ").strip() + port = options[int(ui) - 1].device + print("You picked: %s\n" % port) + return port + + +def find_port_by_mac(mac): + target = normalize_mac(mac) + outgoing = fallback = None + for p in serial.tools.list_ports.comports(): + hwid = (p.hwid or "").upper().replace(':', '').replace('-', '') + if target and target in hwid: + if '_C00000000' in hwid: + outgoing = p.device + elif fallback is None: + fallback = p.device + return outgoing or fallback + + +def resolve_port(arg): + if not arg: + arg = DEFAULT_MAC + if not arg: + return None + a = arg.strip() + if a.upper().startswith('COM') or a.startswith('/dev/'): + return a + n = normalize_mac(a) + if len(n) == 12: + port = find_port_by_mac(n) + print(("MAC %s -> %s" % (a, port)) if port else ("No paired port for MAC %s." % a)) + return port + return a + + +# --- little-endian integer helpers ----------------------------------------- +def u24_le(b0, b1, b2): + return b0 | (b1 << 8) | (b2 << 16) + + +def u16_le(b): + return b[0] | (b[1] << 8) + + +def s16_le(b): + v = u16_le(b) + return v - 0x10000 if v >= 0x8000 else v + + +def s8(b): + return b - 0x100 if b >= 0x80 else b + + +def wait_for_ack(ser, timeout=ACK_TIMEOUT_S): + """Wait up to `timeout` s for a 0xFF ACK. Returns True/False (never hangs).""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + d = ser.read(1) + if d and d[0] == ACK: + return True + return False + + +# --- BMP390 calibration: read 21 NVM coefficients and convert to floats ----- +def read_bmp390_calib(ser): + """Send 0xA7, expect 0xFF 0xA6 <21 bytes>. Returns a dict of par_*.""" + ser.write(struct.pack('B', GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND)) + deadline = time.monotonic() + 3.0 + resp_seen = False + while time.monotonic() < deadline: + b = ser.read(1) + if not b: + continue + if b[0] == NACK: + raise RuntimeError("device NACK'd (0xFE) the calibration command - this is a " + "BMP581 (self-compensating). Use bmp581_plot.py instead.") + if b[0] == PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE: # 0xA6 + resp_seen = True + break + # else: ACK (0xFF) or stray byte - keep scanning + if not resp_seen: + raise RuntimeError("no 0xA6 calibration response received (timeout).") + length = ser.read(1)[0] # 1 (sensor id) + 21 (calib) = 22 + payload = ser.read(length) + sensor_id = payload[0] + c = payload[1:1 + 21] # 21 raw NVM bytes, register order 0x31..0x45 + if len(c) < 21: + raise RuntimeError("short calibration payload (%d bytes)." % len(c)) + + nvm_t1 = u16_le(c[0:2]) # unsigned + nvm_t2 = u16_le(c[2:4]) # unsigned + nvm_t3 = s8(c[4]) + nvm_p1 = s16_le(c[5:7]) + nvm_p2 = s16_le(c[7:9]) + nvm_p3 = s8(c[9]) + nvm_p4 = s8(c[10]) + nvm_p5 = u16_le(c[11:13]) # unsigned + nvm_p6 = u16_le(c[13:15]) # unsigned + nvm_p7 = s8(c[15]) + nvm_p8 = s8(c[16]) + nvm_p9 = s16_le(c[17:19]) + nvm_p10 = s8(c[19]) + nvm_p11 = s8(c[20]) + + par = { + 't1': nvm_t1 / 2.0 ** -8, 't2': nvm_t2 / 2.0 ** 30, 't3': nvm_t3 / 2.0 ** 48, + 'p1': (nvm_p1 - 2.0 ** 14) / 2.0 ** 20, + 'p2': (nvm_p2 - 2.0 ** 14) / 2.0 ** 29, + 'p3': nvm_p3 / 2.0 ** 32, 'p4': nvm_p4 / 2.0 ** 37, + 'p5': nvm_p5 / 2.0 ** -3, 'p6': nvm_p6 / 2.0 ** 6, + 'p7': nvm_p7 / 2.0 ** 8, 'p8': nvm_p8 / 2.0 ** 15, + 'p9': nvm_p9 / 2.0 ** 48, 'p10': nvm_p10 / 2.0 ** 48, + 'p11': nvm_p11 / 2.0 ** 65, + } + print("calibration read (sensor id 0x%02x): 21 BMP3 coefficients OK." % sensor_id) + return par + + +def compensate_temperature(uncomp_temp, par): + d1 = uncomp_temp - par['t1'] + d2 = d1 * par['t2'] + return d2 + (d1 * d1) * par['t3'] # t_lin, degC + + +def compensate_pressure(uncomp_press, t_lin, par): + d1 = par['p6'] * t_lin + d2 = par['p7'] * (t_lin * t_lin) + d3 = par['p8'] * (t_lin * t_lin * t_lin) + out1 = par['p5'] + d1 + d2 + d3 + d1 = par['p2'] * t_lin + d2 = par['p3'] * (t_lin * t_lin) + d3 = par['p4'] * (t_lin * t_lin * t_lin) + out2 = uncomp_press * (par['p1'] + d1 + d2 + d3) + d1 = uncomp_press * uncomp_press + d2 = par['p9'] + par['p10'] * t_lin + d3 = d1 * d2 + d4 = d3 + (uncomp_press * uncomp_press * uncomp_press) * par['p11'] + return out1 + out2 + d4 # Pa + + +def open_serial(port): + ser = serial.Serial(port, 115200, timeout=SERIAL_TIMEOUT_S) + ser.flushInput() + return ser + + +def start_streaming(ser): + """Handshake (SET_SENSORS / rate / START). Calibration is read separately.""" + ser.write(struct.pack('BBBB', SET_SENSORS_COMMAND, SENSORS0, SENSORS1, SENSORS2)) + if not wait_for_ack(ser): + print("WARNING: no ACK for SET_SENSORS.") + clock_wait = int(32768 / DESIRED_SAMPLING_FREQ_HZ) + ser.write(struct.pack(' bmp390_stream.csv."))) + +csv_path = os.path.abspath("bmp390_stream.csv") +csv_file = open(csv_path, "w") +csv_file.write("elapsed_s,pressure_kPa,temperature_C,raw_press,raw_temp\n") + +t_all, p_all, temp_all = [], [], [] + +live = False +if HAVE_MPL: + try: + plt.ion() + fig, (axp, axt) = plt.subplots(2, 1, figsize=(9, 8), sharex=True) + (ln_p,) = axp.plot([], [], color="tab:green", lw=0.8) + (ln_t,) = axt.plot([], [], color="tab:red", lw=0.8) + axp.set_ylabel("Pressure (kPa)") + axt.set_ylabel("Temperature (degC)") + axt.set_xlabel("elapsed (s)") + axp.set_title("Shimmer BMP390 host-compensated (live) - %s" % port) + axp.grid(True, alpha=0.3) + axt.grid(True, alpha=0.3) + fig.tight_layout() + plt.show(block=False) + live = True + except Exception as e: + print("live plot unavailable (%s) - will still save the PNG on exit." % e) + +ddata = bytes() +framesize = 10 # 1 type + 3 ts + 3 pressure + 3 temperature (pressure first) +t0 = time.monotonic() +i = 0 +last_rx = time.monotonic() +stopped_clean = False +try: + while True: + # --- resilient read ------------------------------------------------ + try: + navail = ser.in_waiting + except Exception: + navail = 0 + try: + chunk = ser.read(navail if navail > framesize else framesize) + except (serial.SerialException, OSError) as e: + print("\nserial error: %s" % e) + try: + ser.close() + except Exception: + pass + ser = reconnect(port) + if ser is None: + print("could not reconnect - stopping and saving.") + break + ddata = bytes() + last_rx = time.monotonic() + continue + + now = time.monotonic() + if chunk: + ddata += chunk + last_rx = now + elif now - last_rx > STALL_TIMEOUT_S: + print("\nno data for %.0fs - link stalled, reconnecting ..." % (now - last_rx)) + try: + ser.close() + except Exception: + pass + ser = reconnect(port) + if ser is None: + print("could not reconnect - stopping and saving.") + break + ddata = bytes() + last_rx = time.monotonic() + continue + + # --- parse whatever complete frames we have ------------------------ + while len(ddata) >= framesize: + frame = ddata[0:framesize] + ddata = ddata[framesize:] + try: + (p0, p1, p2, tt0, tt1, tt2) = struct.unpack('BBBBBB', frame[4:framesize]) + uncomp_press = u24_le(p0, p1, p2) + uncomp_temp = u24_le(tt0, tt1, tt2) + t_lin = compensate_temperature(uncomp_temp, par) + press_pa = compensate_pressure(uncomp_press, t_lin, par) + pressure_kpa = press_pa / 1000.0 + temperature_c = t_lin + t = time.monotonic() - t0 + t_all.append(t) + p_all.append(pressure_kpa) + temp_all.append(temperature_c) + csv_file.write("%.4f,%.5f,%.5f,%d,%d\n" + % (t, pressure_kpa, temperature_c, uncomp_press, uncomp_temp)) + csv_file.flush() + i += 1 + except Exception: + continue # skip a malformed frame rather than crash + + if live and (i % 5 == 0): + lo = t - PLOT_WINDOW_S + start = bisect.bisect_left(t_all, lo) # t_all is monotonic + xs = t_all[start:] + ps = p_all[start:] + ts = temp_all[start:] + ln_p.set_data(xs, ps) + ln_t.set_data(xs, ts) + axp.set_xlim(max(0, lo), t + 0.5) + set_ylim_padded(axp, ps, 0.002) + set_ylim_padded(axt, ts, 0.01) + try: + plt.pause(0.001) + if not plt.fignum_exists(fig.number): + stopped_clean = True + break + except Exception: + live = False + if stopped_clean: + break + +except KeyboardInterrupt: + pass +finally: + try: + ser.write(struct.pack('B', STOP_STREAMING_COMMAND)) + wait_for_ack(ser, timeout=1.0) + except Exception: + pass + try: + ser.close() + except Exception: + pass + csv_file.close() + print("\nstopped. %d samples -> %s" % (len(t_all), csv_path)) + if len(temp_all) > 2: + slope, _, r2 = linfit(temp_all, [p * 1000.0 for p in p_all]) + total_pa = (max(p_all) - min(p_all)) * 1000.0 + print("pressure vs temperature: slope = %+.1f Pa/degC R^2 = %.3f excursion = %.0f Pa" + % (slope, r2, total_pa)) + if abs(slope) < 5: + print(" => BMP390 compensated correctly (near the +/-0.6 Pa/degC datasheet spec).") + else: + print(" => residual %.0f Pa/degC (check coefficients / thermal transients)." % slope) + png = save_summary_png(t_all, p_all, temp_all, slope, r2, port) + if png: + print(" plot saved -> %s" % png) + try: + plt.ioff(); plt.show() + except Exception: + pass diff --git a/Extras/python_scripts/Bluetooth commands/bmp581_plot.py b/Extras/python_scripts/Bluetooth commands/bmp581_plot.py new file mode 100644 index 00000000..ff49d314 --- /dev/null +++ b/Extras/python_scripts/Bluetooth commands/bmp581_plot.py @@ -0,0 +1,517 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# BMP581 live PLOT + CSV logger, with an optional host-side TEMPERATURE +# CORRECTION so you can watch raw vs corrected pressure in real time. +# +# * streams pressure (raw/64) + temperature (signed/65536) over BT, +# * applies a quadratic temp correction P_corr = P - (a*(T-Tref)^2 + b*(T-Tref)), +# * live-plots THREE panels: temperature, raw pressure, corrected pressure, +# * logs elapsed, raw kPa, corrected kPa, temp, raw counts to bmp581_stream.csv, +# * on exit prints the raw AND corrected pressure-vs-temperature slope, saves PNG. +# +# ROBUSTNESS: survives Bluetooth dropouts. If the link stalls or errors it closes +# the port, reconnects, and re-issues the streaming commands automatically (up to +# MAX_RECONNECTS times), so a long fridge sweep is not lost to a transient glitch. +# Every sample is flushed to CSV immediately, so data already captured is safe +# even on a hard crash. +# +# The correction coefficients below are PER-UNIT (fit from a fridge sweep). Refit +# for each sensor; set CORRECTION_ENABLE=False to see only the raw comp=3 output. +# +# Usage: bmp581_plot.py COM56 | | --list +# Requires pyserial; matplotlib is auto-installed on first run if missing. + +import bisect +import os +import re +import struct +import subprocess +import sys +import time + +import serial +import serial.tools.list_ports + + +def _ensure_matplotlib(): + try: + import matplotlib # noqa: F401 + return True + except Exception: + print("matplotlib not found - attempting a one-time install via pip ...") + try: + subprocess.check_call([sys.executable, "-m", "pip", "install", "matplotlib"]) + import matplotlib # noqa: F401 + return True + except Exception as e: + print(" auto-install failed (%s). Run manually: pip install matplotlib" % e) + return False + + +HAVE_MPL = _ensure_matplotlib() +if HAVE_MPL: + import matplotlib + try: + matplotlib.use("TkAgg") + except Exception: + pass + import matplotlib.pyplot as plt +else: + print("Continuing without plots - data still logged to bmp581_stream.csv") + +# --- User settings --------------------------------------------------------- +DEFAULT_MAC = "" +DESIRED_SAMPLING_FREQ_HZ = 51.2 +PLOT_WINDOW_S = 120.0 + +# --- Robustness settings --------------------------------------------------- +SERIAL_TIMEOUT_S = 1.0 # per-read timeout (keeps the GUI responsive) +STALL_TIMEOUT_S = 12.0 # no bytes for this long -> treat link as stalled +RECONNECT_WAIT_S = 3.0 # pause between reconnect attempts +MAX_RECONNECTS = 20 # give up after this many consecutive failures +ACK_TIMEOUT_S = 4.0 # how long to wait for a command ACK + +# --- Host-side pressure temperature-correction (PER-UNIT; fit from a fridge sweep) +# P_corr = P_meas - (CORR_C*dt^3 + CORR_A*dt^2 + CORR_B*dt), dt = T_raw - CORR_TREF [Pa] +# Fit to the 2026-07-29 15:22 run (BMP581 UID 1600 2988 86DF C7B2) vs the RAW +# on-chip temperature. Cubic gave negligible gain here (the residual is +# noise-limited until the firmware IIR/OSR fix lands) so the default is quadratic; +# for cubic use CORR_C=0.019269, CORR_A=2.65922, CORR_B=450.4511. +# Set CORRECTION_ENABLE=False to plot only the raw comp=3 output. +CORRECTION_ENABLE = True +CORR_C = 0.0 # Pa/degC^3 +CORR_A = 1.89211 # Pa/degC^2 +CORR_B = 444.3297 # Pa/degC +CORR_TREF = 25.0 # degC +# Constant absolute-alignment offset ADDED to the corrected pressure so it matches +# the co-located BMP390 (the working reference). +1182 Pa (at 25 C) from the +# 2026-07-29 16:52 run; the sensor-to-sensor offset repeated to ~4 Pa at the +# reference temp across 2 runs, but the raw slope drifts ~10% boot-to-boot so this +# is PROVISIONAL pending the power-cycle repeatability test. Set 0.0 to disable. +CORR_OFFSET = 1182.0 # Pa + +# --- BMP581 temperature-channel calibration (2-point) ------------------------- +# The BMP581 temperature is uncompensated too (same broken on-chip engine as +# pressure): it OVER-reads the swing by ~28%. Interim mapping to true temperature +# derived by aligning this unit against a co-located BMP390 +# (T_true ~= 0.782*T_raw + 6.70, RMS 0.76 C). REPLACE with a real 2-point +# measurement: read T_raw in an ice bath (0 C) and at a known warm reference +# against a thermometer, then +# TEMP_CAL_GAIN = (T_hi - T_lo) / (Traw_hi - Traw_lo) +# TEMP_CAL_OFFSET = T_lo - TEMP_CAL_GAIN * Traw_lo +# This ONLY fixes the reported temperature (logged as temperature_cal_C and shown +# on the temp panel); the pressure correction above uses the raw temp and is +# unaffected, so you can re-tune this without refitting the pressure coeffs. +TEMP_CAL_ENABLE = True +TEMP_CAL_GAIN = 0.7822 +TEMP_CAL_OFFSET = 6.695 + +# --- Shimmer BT bytes ------------------------------------------------------ +ACK = 0xFF +NACK = 0xFE +GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND = 0xA7 +PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE = 0xA6 +SET_SENSORS_COMMAND = 0x08 +SET_SAMPLING_RATE_COMMAND = 0x05 +START_STREAMING_COMMAND = 0x07 +STOP_STREAMING_COMMAND = 0x20 +SENSORS0 = 0x00 +SENSORS1 = 0x00 +SENSORS2 = 0x04 # pressure & temperature + + +# --- Port / MAC helpers ---------------------------------------------------- +def normalize_mac(s): + return re.sub(r'[^0-9A-Fa-f]', '', s).upper() + + +def extract_mac_from_hwid(hwid): + h = re.sub(r'\{[^}]*\}', '', hwid or "").replace(':', '') + BASE = '00805F9B34FB' + cands = [c.upper() for c in re.findall(r'([0-9A-Fa-f]{12})', h) + if set(c.upper()) != {'0'} and c.upper() != BASE] + return cands[-1] if cands else None + + +def serial_ports_bluetooth(): + return [p for p in serial.tools.list_ports.comports() + if "Bluetooth" in (p.description or "")] + + +def list_bt_ports(): + print("Bluetooth serial ports (MAC | port | description):") + found = False + for p in serial.tools.list_ports.comports(): + mac = extract_mac_from_hwid(p.hwid) + if mac: + found = True + print(" %-12s %-6s %s" % (mac, p.device, p.description)) + if not found: + print(" (none found - is the device paired?)") + + +def pick_com_port_interactive(): + options = serial_ports_bluetooth() + if not options: + print("No Bluetooth serial ports found - is the Shimmer paired?") + return None + print("Pick an option:") + for index, item in enumerate(options): + mac = extract_mac_from_hwid(item.hwid) or "?" + print(" %d) %s (MAC %s) %s" % (index + 1, item.device, mac, item.description)) + ui = "" + while (not ui.isdigit()) or int(ui) < 1 or int(ui) > len(options): + ui = input("Your choice: ").strip() + port = options[int(ui) - 1].device + print("You picked: %s\n" % port) + return port + + +def find_port_by_mac(mac): + target = normalize_mac(mac) + outgoing = fallback = None + for p in serial.tools.list_ports.comports(): + hwid = (p.hwid or "").upper().replace(':', '').replace('-', '') + if target and target in hwid: + if '_C00000000' in hwid: + outgoing = p.device + elif fallback is None: + fallback = p.device + return outgoing or fallback + + +def resolve_port(arg): + if not arg: + arg = DEFAULT_MAC + if not arg: + return None + a = arg.strip() + if a.upper().startswith('COM') or a.startswith('/dev/'): + return a + n = normalize_mac(a) + if len(n) == 12: + port = find_port_by_mac(n) + print(("MAC %s -> %s" % (a, port)) if port else ("No paired port for MAC %s." % a)) + return port + return a + + +def u24_le(b0, b1, b2): + return b0 | (b1 << 8) | (b2 << 16) + + +def s24_le(b0, b1, b2): + v = u24_le(b0, b1, b2) + return v - 0x1000000 if v >= 0x800000 else v + + +def wait_for_ack(ser, timeout=ACK_TIMEOUT_S): + """Wait up to `timeout` s for a 0xFF ACK. Returns True/False (never hangs).""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + d = ser.read(1) + if d and d[0] == ACK: + return True + return False + + +def check_calibration_nack(ser): + try: + ser.write(struct.pack('B', GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND)) + resp = ser.read(1) + except Exception as e: + print("WARNING: calibration probe failed (%s)." % e) + return + if len(resp) == 0: + print("WARNING: no reply to calibration command (timeout).") + return + b = resp[0] + if b == NACK: + print("calibration command NACK'd (0xFE) - BMP581 pre-compensated. Good.") + elif b == ACK: + hdr = ser.read(1) + if len(hdr) and hdr[0] == PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE: + lenb = ser.read(1) + if len(lenb): + ser.read(lenb[0]) + print("WARNING: device ACK'd coefficients - streaming raw anyway.") + else: + print("WARNING: unexpected reply 0x%02x." % b) + + +def open_serial(port): + ser = serial.Serial(port, 115200, timeout=SERIAL_TIMEOUT_S) + ser.flushInput() + return ser + + +def start_streaming(ser): + """Run the full handshake. Returns True if streaming was (re)started.""" + check_calibration_nack(ser) + ser.write(struct.pack('BBBB', SET_SENSORS_COMMAND, SENSORS0, SENSORS1, SENSORS2)) + if not wait_for_ack(ser): + print("WARNING: no ACK for SET_SENSORS.") + clock_wait = int(32768 / DESIRED_SAMPLING_FREQ_HZ) + ser.write(struct.pack(' kPa.""" + if not CORRECTION_ENABLE: + return pressure_kpa + dt = temperature_c - CORR_TREF + corr_pa = CORR_C * dt * dt * dt + CORR_A * dt * dt + CORR_B * dt + return pressure_kpa - corr_pa / 1000.0 + CORR_OFFSET / 1000.0 + + +def calibrate_temp(temperature_c): + """Map the raw on-chip temperature to true temperature (2-point). -> degC.""" + if not TEMP_CAL_ENABLE: + return temperature_c + return TEMP_CAL_GAIN * temperature_c + TEMP_CAL_OFFSET + + +def save_summary_png(t_all, p_all, pc_all, temp_all, sr, sc, port): + if not HAVE_MPL or len(t_all) < 2: + return None + fig, ax = plt.subplots(3, 1, figsize=(10, 11), sharex=True) + ax[0].plot(t_all, temp_all, color="tab:red", lw=0.5) + ax[0].set_ylabel("Temperature (degC)"); ax[0].grid(alpha=0.3) + ax[0].set_title("BMP581 %s (N=%d, %.0fs)" % (port, len(t_all), t_all[-1] - t_all[0])) + ax[1].plot(t_all, p_all, color="tab:blue", lw=0.5) + ax[1].set_ylabel("Raw pressure (kPa)"); ax[1].grid(alpha=0.3) + ax[1].set_title("raw comp=3: %+.0f Pa/degC" % sr, fontsize=9, loc="right") + ax[2].plot(t_all, pc_all, color="tab:green", lw=0.5) + ax[2].set_ylabel("Corrected pressure (kPa)"); ax[2].set_xlabel("elapsed s"); ax[2].grid(alpha=0.3) + ax[2].set_title("corrected: %+.1f Pa/degC" % sc, fontsize=9, loc="right") + fig.tight_layout() + png = os.path.abspath("bmp581_plot.png") + fig.savefig(png, dpi=110) + return png + + +# --- Main ------------------------------------------------------------------ +raw_args = sys.argv[1:] +if any(a in ('--list', '-l', 'list') for a in raw_args): + list_bt_ports() + sys.exit(0) + +arg = "".join(raw_args).strip() if raw_args else None +port = resolve_port(arg) or pick_com_port_interactive() +if not port: + print("No device selected. Pass a COM port or MAC (see --list).") + sys.exit(1) + +ser = open_serial(port) +print("port %s opened." % port) +if CORRECTION_ENABLE: + print("temp correction ON: P_corr = P - (%.4f*(T-%.1f)^2 + %.3f*(T-%.1f))" + % (CORR_A, CORR_TREF, CORR_B, CORR_TREF)) +else: + print("temp correction OFF (raw comp=3 only).") + +if not start_streaming(ser): + print("streaming did not start cleanly - continuing to read anyway.") +print("streaming at %.1f Hz. %s\n" + % (DESIRED_SAMPLING_FREQ_HZ, + ("Close the plot window or press Ctrl+C to stop." if HAVE_MPL + else "Press Ctrl+C to stop; data -> bmp581_stream.csv."))) + +csv_path = os.path.abspath("bmp581_stream.csv") +csv_file = open(csv_path, "w") +csv_file.write("elapsed_s,pressure_kPa,pressure_corr_kPa,temperature_C,temperature_cal_C,raw_press,raw_temp\n") + +t_all, p_all, pc_all, temp_all = [], [], [], [] + +live = False +if HAVE_MPL: + try: + plt.ion() + fig, (axt, axp, axpc) = plt.subplots(3, 1, figsize=(9, 10), sharex=True) + (ln_t,) = axt.plot([], [], color="tab:red", lw=0.8) + (ln_p,) = axp.plot([], [], color="tab:blue", lw=0.8) + (ln_pc,) = axpc.plot([], [], color="tab:green", lw=0.8) + axt.set_ylabel("Temperature (degC, cal)" if TEMP_CAL_ENABLE else "Temperature (degC)") + axp.set_ylabel("Raw pressure (kPa)") + axpc.set_ylabel("Corrected pressure (kPa)") + axpc.set_xlabel("elapsed (s)") + axt.set_title("Shimmer BMP581 live - temperature / raw / corrected (%s)" % port) + for a in (axt, axp, axpc): + a.grid(True, alpha=0.3) + fig.tight_layout() + plt.show(block=False) + live = True + except Exception as e: + print("live plot unavailable (%s) - will still save the PNG on exit." % e) + +ddata = bytes() +framesize = 10 +t0 = time.monotonic() +i = 0 +last_rx = time.monotonic() +stopped_clean = False +try: + while True: + # --- resilient read ------------------------------------------------ + try: + navail = ser.in_waiting + except Exception: + navail = 0 + try: + chunk = ser.read(navail if navail > framesize else framesize) + except (serial.SerialException, OSError) as e: + print("\nserial error: %s" % e) + try: + ser.close() + except Exception: + pass + ser = reconnect(port) + if ser is None: + print("could not reconnect - stopping and saving.") + break + ddata = bytes() + last_rx = time.monotonic() + continue + + now = time.monotonic() + if chunk: + ddata += chunk + last_rx = now + elif now - last_rx > STALL_TIMEOUT_S: + print("\nno data for %.0fs - link stalled, reconnecting ..." % (now - last_rx)) + try: + ser.close() + except Exception: + pass + ser = reconnect(port) + if ser is None: + print("could not reconnect - stopping and saving.") + break + ddata = bytes() + last_rx = time.monotonic() + continue + + # --- parse whatever complete frames we have ------------------------ + while len(ddata) >= framesize: + frame = ddata[0:framesize] + ddata = ddata[framesize:] + try: + (p0, p1, p2, tt0, tt1, tt2) = struct.unpack('BBBBBB', frame[4:framesize]) + pressure_kpa = u24_le(p0, p1, p2) / 64000.0 + temperature_c = s24_le(tt0, tt1, tt2) / 65536.0 + temperature_cal = calibrate_temp(temperature_c) + pressure_corr_kpa = correct_pa(pressure_kpa, temperature_c) + t = time.monotonic() - t0 + t_all.append(t) + p_all.append(pressure_kpa) + pc_all.append(pressure_corr_kpa) + temp_all.append(temperature_cal) # plot/report the calibrated temp + csv_file.write("%.4f,%.5f,%.5f,%.5f,%.5f,%d,%d\n" + % (t, pressure_kpa, pressure_corr_kpa, temperature_c, + temperature_cal, u24_le(p0, p1, p2), s24_le(tt0, tt1, tt2))) + csv_file.flush() + i += 1 + except Exception: + continue # skip a malformed frame rather than crash + + if live and (i % 5 == 0): + lo = t - PLOT_WINDOW_S + start = bisect.bisect_left(t_all, lo) # t_all is monotonic + xs = t_all[start:] + ps = p_all[start:] + pcs = pc_all[start:] + ts = temp_all[start:] + ln_t.set_data(xs, ts) + ln_p.set_data(xs, ps) + ln_pc.set_data(xs, pcs) + axpc.set_xlim(max(0, lo), t + 0.5) + set_ylim_padded(axt, ts, 0.01) + set_ylim_padded(axp, ps, 0.002) + set_ylim_padded(axpc, pcs, 0.002) + try: + plt.pause(0.001) + if not plt.fignum_exists(fig.number): + stopped_clean = True + break + except Exception: + live = False + if stopped_clean: + break + +except KeyboardInterrupt: + pass +finally: + try: + ser.write(struct.pack('B', STOP_STREAMING_COMMAND)) + wait_for_ack(ser, timeout=1.0) + except Exception: + pass + try: + ser.close() + except Exception: + pass + csv_file.close() + print("\nstopped. %d samples -> %s" % (len(t_all), csv_path)) + sr = sc = 0.0 + if len(temp_all) > 2: + sr, _, r2r = linfit(temp_all, [p * 1000.0 for p in p_all]) + sc, _, r2c = linfit(temp_all, [p * 1000.0 for p in pc_all]) + print("pressure vs temperature slope:") + print(" RAW comp=3 : %+.1f Pa/degC (R2=%.3f)" % (sr, r2r)) + if CORRECTION_ENABLE: + print(" CORRECTED : %+.2f Pa/degC (R2=%.3f) <- should be near 0" % (sc, r2c)) + png = save_summary_png(t_all, p_all, pc_all, temp_all, sr, sc, port) + if png: + print(" plot saved -> %s" % png) + try: + plt.ioff(); plt.show() + except Exception: + pass diff --git a/Extras/python_scripts/Bluetooth commands/bmp_compare.py b/Extras/python_scripts/Bluetooth commands/bmp_compare.py new file mode 100644 index 00000000..faaec519 --- /dev/null +++ b/Extras/python_scripts/Bluetooth commands/bmp_compare.py @@ -0,0 +1,246 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Combine the outputs of bmp581_plot.py and bmp390_plot.py into ONE figure. +# +# Run the two live scripts (ideally started together) during a fridge test: +# python bmp581_plot.py COM -> bmp581_stream.csv +# python bmp390_plot.py COM -> bmp390_stream.csv +# then run this to overlay them: +# python bmp_compare.py +# python bmp_compare.py # explicit paths +# +# The result is FIVE stacked panels, each on its OWN independent y-scale, sharing +# the elapsed-time axis: +# 1) BMP581 temperature +# 2) BMP581 original pressure (raw comp=3) +# 3) BMP581 corrected pressure +# 4) BMP390 temperature +# 5) BMP390 pressure (host-compensated) +# Independent scales mean a well-corrected BMP581 and the BMP390 both look flat +# even though the two chips sit at different absolute offsets ("not very off"). +# +# Requires matplotlib (auto-installed on first run if missing). + +import csv +import os +import subprocess +import sys + +DEFAULT_581 = "bmp581_stream.csv" +DEFAULT_390 = "bmp390_stream.csv" + +COL_RAW = "tab:blue" +COL_COR = "tab:green" +COL_390 = "tab:red" + + +def _ensure_matplotlib(): + try: + import matplotlib # noqa: F401 + return True + except Exception: + print("matplotlib not found - attempting a one-time install via pip ...") + try: + subprocess.check_call([sys.executable, "-m", "pip", "install", "matplotlib"]) + import matplotlib # noqa: F401 + return True + except Exception as e: + print(" auto-install failed (%s). Run manually: pip install matplotlib" % e) + return False + + +HAVE_MPL = _ensure_matplotlib() +if HAVE_MPL: + import matplotlib + try: + matplotlib.use("TkAgg") + except Exception: + pass + import matplotlib.pyplot as plt + + +def find_csv(name): + """Look in cwd first, then next to this script.""" + if os.path.isfile(name): + return os.path.abspath(name) + here = os.path.join(os.path.dirname(os.path.abspath(__file__)), name) + return here if os.path.isfile(here) else None + + +def read_stream_csv(path): + """Read a bmpXXX_stream.csv into {column_name: [floats]} keyed by header.""" + cols = {} + with open(path, "r") as f: + reader = csv.reader(f) + header = [h.strip() for h in next(reader)] + for h in header: + cols[h] = [] + for row in reader: + if len(row) < len(header): + continue + vals = [] + ok = True + for v in row[:len(header)]: + try: + vals.append(float(v)) + except ValueError: + ok = False + break + if not ok: + continue + for h, v in zip(header, vals): + cols[h].append(v) + return cols + + +def col(cols, *names): + for n in names: + if n in cols and cols[n]: + return cols[n] + return None + + +def linfit(x, y): + n = min(len(x), len(y)) + if n < 2: + return 0.0, 0.0 + x = x[:n]; y = y[:n] + mx = sum(x) / n + my = sum(y) / n + sxx = sum((xi - mx) ** 2 for xi in x) + sxy = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y)) + syy = sum((yi - my) ** 2 for yi in y) + if sxx == 0 or syy == 0: + return 0.0, 0.0 + return sxy / sxx, (sxy * sxy) / (sxx * syy) + + +def report(label, temp, press_kpa): + if not temp or not press_kpa: + return + slope, r2 = linfit(temp, [p * 1000.0 for p in press_kpa]) + span = (max(press_kpa) - min(press_kpa)) * 1000.0 + mean = sum(press_kpa) / len(press_kpa) + print(" %-24s slope %+8.2f Pa/degC R2=%.4f span=%6.0f Pa mean=%.3f kPa" + % (label, slope, r2, span, mean)) + + +# --- Load ------------------------------------------------------------------ +NO_SHOW = ("--no-show" in sys.argv) or bool(os.environ.get("BMP_NOSHOW")) +args = [a for a in sys.argv[1:] if a and not a.startswith("-")] +p581 = args[0] if len(args) >= 1 else DEFAULT_581 +p390 = args[1] if len(args) >= 2 else DEFAULT_390 +path581 = find_csv(p581) +path390 = find_csv(p390) + +if not path581 and not path390: + print("No input CSVs found. Expected %s and/or %s in this folder." % (DEFAULT_581, DEFAULT_390)) + print("Run bmp581_plot.py / bmp390_plot.py first, or pass paths as arguments.") + sys.exit(1) + +d581 = read_stream_csv(path581) if path581 else {} +d390 = read_stream_csv(path390) if path390 else {} + +t581 = col(d581, "elapsed_s") +p581_raw = col(d581, "pressure_kPa") +p581_cor = col(d581, "pressure_corr_kPa") or p581_raw # fall back if not corrected +temp581 = col(d581, "temperature_cal_C", "temperature_C") # prefer calibrated temp + +t390 = col(d390, "elapsed_s") +p390_c = col(d390, "pressure_kPa") +temp390 = col(d390, "temperature_C") + +print("\nInputs:") +if path581: + print(" BMP581: %s (%d samples)" % (path581, len(t581 or []))) +if path390: + print(" BMP390: %s (%d samples)" % (path390, len(t390 or []))) + +print("\nPressure vs temperature (flatness):") +report("BMP581 raw comp=3", temp581, p581_raw) +if col(d581, "pressure_corr_kPa"): + report("BMP581 corrected", temp581, p581_cor) +report("BMP390 host-compensated", temp390, p390_c) + +# Absolute offset of the corrected BMP581 vs the BMP390 at the 25 C reference. +# Weather cancels (co-located), so this is the run-to-run repeatability metric: +# tabulate it across reboots - near-constant => a fixed offset holds. +def value_at(temp, p_kpa, tref=25.0): + """Linear-fit pressure (Pa) evaluated at tref.""" + pa = [p * 1000.0 for p in p_kpa] + s, _ = linfit(temp, pa) + mt = sum(temp) / len(temp) + mp = sum(pa) / len(pa) + return mp - s * (mt - tref) + + +if temp581 and p581_cor and temp390 and p390_c: + off = value_at(temp390, p390_c) - value_at(temp581, p581_cor) + print("\nAbsolute offset at 25 C: BMP390 - corrected BMP581 = %+.0f Pa" % off) + print(" (~0 means the CORR_OFFSET in bmp581_plot.py is aligned; track this across reboots)") + +if not HAVE_MPL: + print("\n(matplotlib unavailable - stats only.)") + sys.exit(0) + + +# --- Plot ------------------------------------------------------------------ +# Five stacked panels, each on its OWN independent y-scale, sharing the time axis: +# 1) BMP581 temperature +# 2) BMP581 original pressure (raw comp=3) +# 3) BMP581 corrected pressure +# 4) BMP390 temperature +# 5) BMP390 pressure (host-compensated) +# For a pressure panel, its pressure-vs-temperature slope is shown at the right. +COL_T = "tab:red" +has_corr = bool(col(d581, "pressure_corr_kPa")) + + +def slope_note(temp, press_kpa): + if not temp or not press_kpa: + return "" + s, r2 = linfit(temp, [p * 1000.0 for p in press_kpa]) + return "%+.1f Pa/degC R2=%.3f" % (s, r2) + + +# (title, ylabel, x, y, colour, temp_for_slope_or_None) +panels = [] +if t581 and temp581: + panels.append(("BMP581 temperature", "Temp (degC)", t581, temp581, COL_T, None)) +if t581 and p581_raw: + panels.append(("BMP581 original pressure (raw comp=3)", "Pressure (kPa)", + t581, p581_raw, COL_RAW, temp581)) +if t581 and has_corr and p581_cor: + panels.append(("BMP581 corrected pressure", "Pressure (kPa)", + t581, p581_cor, COL_COR, temp581)) +if t390 and temp390: + panels.append(("BMP390 temperature", "Temp (degC)", t390, temp390, COL_T, None)) +if t390 and p390_c: + panels.append(("BMP390 pressure (compensated)", "Pressure (kPa)", + t390, p390_c, COL_390, temp390)) + +if not panels: + print("Nothing to plot - CSVs had no usable columns.") + sys.exit(1) + +n = len(panels) +fig, axes = plt.subplots(n, 1, figsize=(11, 2.2 * n), sharex=True) +if n == 1: + axes = [axes] + +for ax, (title, ylabel, x, y, colour, temp_ref) in zip(axes, panels): + ax.plot(x, y, color=colour, lw=0.7) + ax.set_ylabel(ylabel) + note = slope_note(temp_ref, y) if temp_ref is not None else "" + ax.set_title(title + ((" [%s]" % note) if note else ""), fontsize=9, loc="left") + ax.grid(True, alpha=0.3) + +axes[-1].set_xlabel("elapsed (s) [BMP581 and BMP390 line up only if both scripts started together]") +fig.suptitle("BMP581 vs BMP390 - combined (independent scales)", fontsize=11) +fig.tight_layout(rect=(0, 0, 1, 0.99)) +out = os.path.abspath("bmp_compare.png") +fig.savefig(out, dpi=110) +print("\ncombined plot (%d panels) saved -> %s" % (n, out)) +if not NO_SHOW: + plt.show()