/ firmware / src / hardware / i2c.cpp
i2c.cpp
  1  #include "i2c.h"
  2  #include <config.h>
  3  #include "console/icons.h"
  4  
  5  #include <Arduino.h>
  6  #include <Wire.h>
  7  
  8  static bool legacy_power_enabled = false;
  9  static bool mux_present = false;
 10  static bool mux_odd_power_enabled = false;
 11  static bool mux_even_power_enabled = false;
 12  
 13  TCA9548 hardware::i2c::mux(config::i2c::MUX_ADDR, &Wire1);
 14  
 15  namespace {
 16  
 17  void disable_legacy_power_rail(void) {
 18    if (!legacy_power_enabled) return;
 19    digitalWrite(config::i2c::LEGACY_POWER_GPIO, LOW);
 20    legacy_power_enabled = false;
 21  }
 22  
 23  void enable_legacy_power_rail(void) {
 24    if (legacy_power_enabled) return;
 25    pinMode(config::i2c::LEGACY_POWER_GPIO, OUTPUT);
 26    digitalWrite(config::i2c::LEGACY_POWER_GPIO, HIGH);
 27    delay(100);
 28    legacy_power_enabled = true;
 29  }
 30  
 31  void disable_mux_power_rails(void) {
 32    pinMode(config::i2c::MUX_POWER_GPIO_ODD, OUTPUT);
 33    pinMode(config::i2c::MUX_POWER_GPIO_EVEN, OUTPUT);
 34    digitalWrite(config::i2c::MUX_POWER_GPIO_ODD, LOW);
 35    digitalWrite(config::i2c::MUX_POWER_GPIO_EVEN, LOW);
 36    mux_odd_power_enabled = false;
 37    mux_even_power_enabled = false;
 38  }
 39  
 40  void enable_mux_power_for_channel(int8_t mux_channel) {
 41    // New board routing is fixed and intentionally spelled out here:
 42    //   channels 0,2,4,6 -> GPIO 6
 43    //   channels 1,3,5,7 -> GPIO 1
 44    switch (mux_channel) {
 45      case 0:
 46      case 2:
 47      case 4:
 48      case 6:
 49        pinMode(config::i2c::MUX_POWER_GPIO_EVEN, OUTPUT);
 50        digitalWrite(config::i2c::MUX_POWER_GPIO_EVEN, HIGH);
 51        digitalWrite(config::i2c::MUX_POWER_GPIO_ODD, LOW);
 52        mux_even_power_enabled = true;
 53        mux_odd_power_enabled = false;
 54        break;
 55  
 56      case 1:
 57      case 3:
 58      case 5:
 59      case 7:
 60        pinMode(config::i2c::MUX_POWER_GPIO_ODD, OUTPUT);
 61        digitalWrite(config::i2c::MUX_POWER_GPIO_ODD, HIGH);
 62        digitalWrite(config::i2c::MUX_POWER_GPIO_EVEN, LOW);
 63        mux_odd_power_enabled = true;
 64        mux_even_power_enabled = false;
 65        break;
 66  
 67      default:
 68        disable_mux_power_rails();
 69        break;
 70    }
 71    delay(config::i2c::POWER_SETTLE_MS);
 72  }
 73  
 74  }
 75  
 76  void hardware::i2c::enable() {
 77    if (!mux_present) {
 78      enable_legacy_power_rail();
 79    }
 80  }
 81  
 82  void hardware::i2c::disable() {
 83    if (!mux_present) {
 84      disable_legacy_power_rail();
 85      return;
 86    }
 87    disable_mux_power_rails();
 88  }
 89  
 90  bool hardware::i2c::isEnabled() {
 91    if (!mux_present) {
 92      return legacy_power_enabled;
 93    }
 94    return mux_odd_power_enabled || mux_even_power_enabled;
 95  }
 96  
 97  bool hardware::i2c::initialize() {
 98    Wire.begin(config::i2c::BUS_0.sda_gpio, config::i2c::BUS_0.scl_gpio,
 99               config::i2c::FREQUENCY_KHZ * 1000);
100    Wire.setTimeOut(100);
101  
102    Wire1.begin(config::i2c::BUS_1.sda_gpio, config::i2c::BUS_1.scl_gpio,
103                config::i2c::FREQUENCY_KHZ * 1000);
104    Wire1.setTimeOut(100);
105  
106    mux_present = mux.begin();
107    return true;
108  }
109  
110  bool hardware::i2c::accessBus(BusDescriptor *descriptor) {
111    if (!descriptor) return false;
112  
113    switch (descriptor->bus) {
114      case Bus::Bus0:
115        descriptor->wire = &Wire;
116        descriptor->ready = true;
117        return true;
118      case Bus::Bus1:
119        descriptor->wire = &Wire1;
120        descriptor->ready = true;
121        return true;
122      default:
123        descriptor->wire = nullptr;
124        descriptor->ready = false;
125        return false;
126    }
127  }
128  
129  bool hardware::i2c::accessTopology(TopologySnapshot *snapshot) {
130    if (!snapshot) return false;
131    snapshot->legacy_power_enabled = legacy_power_enabled;
132    snapshot->mux_present = mux_present;
133    snapshot->mux_address = config::i2c::MUX_ADDR;
134    snapshot->mux_odd_power_enabled = mux_odd_power_enabled;
135    snapshot->mux_even_power_enabled = mux_even_power_enabled;
136    return true;
137  }
138  
139  bool hardware::i2c::accessDevice(DeviceAccessCommand *command) {
140    if (!command) return false;
141  
142    BusDescriptor descriptor = {
143      .bus = command->bus,
144      .wire = nullptr,
145      .ready = false,
146    };
147    if (!hardware::i2c::accessBus(&descriptor) || !descriptor.ready) {
148      command->wire = nullptr;
149      command->ok = false;
150      return false;
151    }
152  
153    if (command->mux_channel >= 0) {
154      if (!mux_present || command->bus != Bus::Bus1) {
155        command->wire = nullptr;
156        command->ok = false;
157        return false;
158      }
159      enable_mux_power_for_channel(command->mux_channel);
160      if (!mux.selectChannel((uint8_t)command->mux_channel)) {
161        command->wire = nullptr;
162        command->ok = false;
163        return false;
164      }
165    } else if (!mux_present) {
166      enable_legacy_power_rail();
167    }
168  
169    command->wire = descriptor.wire;
170    command->ok = true;
171    return true;
172  }
173  
174  // clearSelection() not only deselects mux channels, it also drops both
175  // ODD/EVEN MUX_POWER_GPIO rails. This is intentional power saving, but it
176  // means any sensor that relies on internal state across reads (e.g., SCD30
177  // periodic measurement) cannot work on this board — the rail is cut between
178  // polls. Stateless sensors and SCD41 single-shot mode are compatible; see
179  // sensors/carbon_dioxide.cpp for the working pattern.
180  void hardware::i2c::clearSelection() {
181    if (mux_present) {
182      mux.disableAllChannels();
183      disable_mux_power_rails();
184    }
185  }
186  
187  static inline int clamp(int pos, size_t limit) {
188    return (pos >= (int)limit) ? (int)limit - 1 : pos;
189  }
190  
191  bool hardware::i2c::scan(ScanCommand *command) {
192    if (!command || !command->buffer || command->capacity == 0) return false;
193    char *buf = command->buffer;
194    size_t buf_size = command->capacity;
195    int pos = 0;
196  
197    // Scan raw buses
198    pos += snprintf(buf + pos, buf_size - pos, "bus 0:\r\n");
199    for (uint8_t addr = config::i2c::ADDR_MIN; addr < config::i2c::ADDR_MAX && pos < (int)buf_size - 16; addr++) {
200      Wire.beginTransmission(addr);
201      if (Wire.endTransmission() == 0) {
202        pos += snprintf(buf + pos, buf_size - pos, "  0x%02X\r\n", addr);
203        pos = clamp(pos, buf_size);
204      }
205    }
206  
207    pos += snprintf(buf + pos, buf_size - pos, "bus 1:\r\n");
208    for (uint8_t addr = config::i2c::ADDR_MIN; addr < config::i2c::ADDR_MAX && pos < (int)buf_size - 16; addr++) {
209      Wire1.beginTransmission(addr);
210      if (Wire1.endTransmission() == 0) {
211        pos += snprintf(buf + pos, buf_size - pos, "  0x%02X\r\n", addr);
212        pos = clamp(pos, buf_size);
213      }
214    }
215  
216    // Scan mux channels
217    if (mux_present) {
218      pos += snprintf(buf + pos, buf_size - pos, "mux:\r\n");
219      pos = clamp(pos, buf_size);
220  
221      for (uint8_t ch = 0; ch < mux.channelCount() && pos < (int)buf_size - 32; ch++) {
222        mux.selectChannel(ch);
223        int found = 0;
224        pos += snprintf(buf + pos, buf_size - pos, "  ch %d:", ch);
225        pos = clamp(pos, buf_size);
226  
227        for (uint8_t addr = config::i2c::ADDR_MIN; addr < config::i2c::ADDR_MAX && pos < (int)buf_size - 16; addr++) {
228          Wire1.beginTransmission(addr);
229          if (Wire1.endTransmission() == 0) {
230            pos += snprintf(buf + pos, buf_size - pos, " 0x%02X", addr);
231            pos = clamp(pos, buf_size);
232            found++;
233          }
234        }
235  
236        if (found == 0) {
237          pos += snprintf(buf + pos, buf_size - pos, " (empty)");
238          pos = clamp(pos, buf_size);
239        }
240        pos += snprintf(buf + pos, buf_size - pos, "\r\n");
241        pos = clamp(pos, buf_size);
242      }
243  
244      hardware::i2c::clearSelection();
245    } else {
246      pos += snprintf(buf + pos, buf_size - pos, "mux: (not present)\r\n");
247      pos = clamp(pos, buf_size);
248    }
249  
250    command->length = pos;
251    return true;
252  }
253  
254  static const char *device_icon_at(uint8_t address) {
255    switch (address) {
256      case 0x39: return NF_FA_SIGNAL;            // AS7343 spectral (placeholder)
257      case 0x40: return NF_FA_BOLT;              // INA228 current
258      case 0x44: return NF_FA_THERMOMETER;       // SHT3x temperature
259      case 0x48: return NF_FA_SIGNAL;            // ADS1115 ADC
260      case 0x50: return NF_FA_DATABASE;          // EEPROM
261      case 0x5C: case 0x5D: return NF_FA_TINT;  // LPS25 pressure
262      case 0x61: case 0x62: return NF_FA_LEAF;   // SCD30/SCD41 CO2
263      case 0x67: return NF_FA_THERMOMETER;       // MCP9600 thermocouple
264      case 0x68: return NF_FA_CLOCK;             // DS3231 RTC
265      case 0x70: return NF_FA_SITEMAP;           // TCA9548A mux
266      default:   return NF_FA_COG;               // unknown
267    }
268  }
269  
270  const char *hardware::i2c::deviceNameAt(uint8_t address, int8_t mux_channel) {
271    bool is_muxed = (mux_channel >= 0);
272  
273    switch (address) {
274      case 0x39: return "Sparkfun AS7343 Spectral Sensor";
275      case 0x40: return "Adafruit INA228 Current Monitor";
276      case 0x44: return "Sensirion SHT3x Temperature & Humidity Sensor";
277      case 0x48: return "ADS1115 16-Bit ADC - 4 Channel with Programmable Gain Amplifier";
278      case 0x50: return is_muxed ? "sensor module EEPROM (on-board)"
279                                 : "Microchip Technology AT24C32 EEPROM";
280      case 0x5C: case 0x5D: return "Adafruit LPS25 Pressure Sensor";
281      case 0x61: return "Sensirion SCD30 CO2 Infrared Gas Sensor";
282      case 0x62: return "Sensirion SCD41 CO2 Optical Gas Sensor";
283      case 0x67: return "Adafruit MCP9600 Thermocouple Amplifier";
284      case 0x68: return "Analog Devices DS3231 RTC";
285      case 0x70: return "Adafruit TCA9548A 1-to-8 I2C Multiplexer Breakout";
286      default:   return "unknown";
287    }
288  }
289  
290  size_t hardware::i2c::discoverAll(DiscoveredDevice *devices, size_t capacity) {
291    if (!devices || capacity == 0) return 0;
292    size_t count = 0;
293  
294    enable_legacy_power_rail();
295    pinMode(config::i2c::MUX_POWER_GPIO_EVEN, OUTPUT);
296    digitalWrite(config::i2c::MUX_POWER_GPIO_EVEN, HIGH);
297    pinMode(config::i2c::MUX_POWER_GPIO_ODD, OUTPUT);
298    digitalWrite(config::i2c::MUX_POWER_GPIO_ODD, HIGH);
299    delay(config::i2c::DISCOVERY_SETTLE_MS);
300  
301    TwoWire *buses[] = { &Wire, &Wire1 };
302    for (uint8_t bus = 0; bus < 2 && count < capacity; bus++) {
303      for (uint8_t addr = config::i2c::ADDR_MIN; addr <= config::i2c::ADDR_MAX && count < capacity; addr++) {
304        if (addr == config::i2c::MUX_ADDR) continue;
305        buses[bus]->beginTransmission(addr);
306        if (buses[bus]->endTransmission() == 0) {
307          devices[count++] = { bus, addr, -1 };
308        }
309      }
310    }
311  
312    if (mux_present) {
313      for (uint8_t ch = 0; ch < mux.channelCount() && count < capacity; ch++) {
314        enable_mux_power_for_channel(ch);
315        mux.selectChannel(ch);
316        for (uint8_t addr = config::i2c::ADDR_MIN; addr <= config::i2c::ADDR_MAX && count < capacity; addr++) {
317          if (addr == config::i2c::MUX_ADDR) continue;
318          Wire1.beginTransmission(addr);
319          if (Wire1.endTransmission() == 0) {
320            devices[count++] = { 1, addr, (int8_t)ch };
321          }
322        }
323        mux.disableAllChannels();
324      }
325      disable_mux_power_rails();
326    }
327  
328    return count;
329  }
330  
331  static hardware::i2c::DiscoveredDevice discovery_cache[hardware::i2c::MAX_DISCOVERED_DEVICES];
332  static size_t discovery_count = 0;
333  static bool discovery_done = false;
334  
335  bool hardware::i2c::runDiscovery() {
336    discovery_count = discoverAll(discovery_cache, MAX_DISCOVERED_DEVICES);
337    discovery_done = true;
338    for (size_t i = 0; i < discovery_count; i++) {
339      Serial.printf("[i2c] found 0x%02X on bus %d%s\n",
340                    discovery_cache[i].address, discovery_cache[i].bus,
341                    discovery_cache[i].mux_channel >= 0 ? " (mux)" : "");
342    }
343    return discovery_count > 0;
344  }
345  
346  // Searches the cached discovery results. Call runDiscovery() first
347  // during boot. Falls back to a full bus scan if the cache is empty.
348  bool hardware::i2c::findDevice(uint8_t address, DiscoveredDevice *result) {
349    if (!result) return false;
350    if (!discovery_done) runDiscovery();
351    for (size_t i = 0; i < discovery_count; i++) {
352      if (discovery_cache[i].address == address) {
353        *result = discovery_cache[i];
354        return true;
355      }
356    }
357    return false;
358  }
359  
360  // ─────────────────────────────────────────────────────────────────────────────
361  //  Tests
362  // ─────────────────────────────────────────────────────────────────────────────
363  #ifdef PIO_UNIT_TESTING
364  
365  #include <testing/utils.h>
366  
367  
368  static void test_i2c_mux_init(void) {
369    GIVEN("Wire1 is available");
370    test_ensure_wire1();
371  
372    WHEN("the mux is initialized");
373    hardware::i2c::initialize();
374    hardware::i2c::TopologySnapshot snapshot = {};
375    hardware::i2c::accessTopology(&snapshot);
376    if (!snapshot.mux_present) {
377      TEST_IGNORE_MESSAGE("mux not present on this board");
378      return;
379    }
380  }
381  
382  static void test_i2c_mux_is_connected(void) {
383    WHEN("the mux connection is checked");
384    hardware::i2c::TopologySnapshot snapshot = {};
385    hardware::i2c::accessTopology(&snapshot);
386    if (!snapshot.mux_present) {
387      TEST_IGNORE_MESSAGE("mux not present on this board");
388      return;
389    }
390    TEST_ASSERT_TRUE_MESSAGE(snapshot.mux_present,
391      "device: mux not found at 0x70");
392  }
393  
394  static void test_i2c_mux_channel_count(void) {
395    THEN("the mux has 8 channels");
396    hardware::i2c::TopologySnapshot snapshot = {};
397    hardware::i2c::accessTopology(&snapshot);
398    if (!snapshot.mux_present) {
399      TEST_IGNORE_MESSAGE("mux not present on this board");
400      return;
401    }
402    TEST_ASSERT_EQUAL_UINT8_MESSAGE(8, hardware::i2c::mux.channelCount(),
403      "device: TCA9548A should have 8 channels");
404  }
405  
406  static void test_i2c_mux_select_and_verify(void) {
407    WHEN("channels are selected");
408    hardware::i2c::TopologySnapshot snapshot = {};
409    hardware::i2c::accessTopology(&snapshot);
410    if (!snapshot.mux_present) {
411      TEST_IGNORE_MESSAGE("mux not present on this board");
412      return;
413    }
414  
415    TEST_ASSERT_FALSE_MESSAGE(snapshot.mux_even_power_enabled,
416      "device: even mux rail should start disabled");
417    TEST_ASSERT_FALSE_MESSAGE(snapshot.mux_odd_power_enabled,
418      "device: odd mux rail should start disabled");
419  
420    TEST_ASSERT_TRUE_MESSAGE(hardware::i2c::mux.selectChannel(0),
421      "device: selectChannel(0) failed");
422    TEST_ASSERT_BIT_HIGH_MESSAGE(0, hardware::i2c::mux.getChannelMask(),
423      "device: bit 0 should be high after select(0)");
424  
425    TEST_ASSERT_TRUE_MESSAGE(hardware::i2c::mux.selectChannel(3),
426      "device: selectChannel(3) failed");
427    TEST_ASSERT_BIT_HIGH_MESSAGE(3, hardware::i2c::mux.getChannelMask(),
428      "device: bit 3 should be high after select(3)");
429    TEST_ASSERT_BIT_LOW_MESSAGE(0, hardware::i2c::mux.getChannelMask(),
430      "device: bit 0 should be low after select(3) — exclusive select");
431  
432    hardware::i2c::mux.disableAllChannels();
433  }
434  
435  static void test_i2c_mux_channel_power_mapping(void) {
436    WHEN("even and odd mux channels are accessed");
437    hardware::i2c::TopologySnapshot snapshot = {};
438    hardware::i2c::accessTopology(&snapshot);
439    if (!snapshot.mux_present) {
440      TEST_IGNORE_MESSAGE("mux not present on this board");
441      return;
442    }
443  
444    hardware::i2c::DeviceAccessCommand even_command = {
445      .bus = hardware::i2c::Bus::Bus1,
446      .mux_channel = 0,
447      .wire = nullptr,
448      .ok = false,
449    };
450    TEST_ASSERT_TRUE_MESSAGE(hardware::i2c::accessDevice(&even_command),
451      "device: accessDevice failed for mux channel 0");
452    hardware::i2c::accessTopology(&snapshot);
453    TEST_ASSERT_TRUE_MESSAGE(snapshot.mux_even_power_enabled,
454      "device: even mux channels should enable GPIO 6 rail");
455    TEST_ASSERT_FALSE_MESSAGE(snapshot.mux_odd_power_enabled,
456      "device: odd mux rail should remain off for even channels");
457    hardware::i2c::clearSelection();
458  
459    hardware::i2c::DeviceAccessCommand odd_command = {
460      .bus = hardware::i2c::Bus::Bus1,
461      .mux_channel = 1,
462      .wire = nullptr,
463      .ok = false,
464    };
465    TEST_ASSERT_TRUE_MESSAGE(hardware::i2c::accessDevice(&odd_command),
466      "device: accessDevice failed for mux channel 1");
467    hardware::i2c::accessTopology(&snapshot);
468    TEST_ASSERT_TRUE_MESSAGE(snapshot.mux_odd_power_enabled,
469      "device: odd mux channels should enable GPIO 1 rail");
470    TEST_ASSERT_FALSE_MESSAGE(snapshot.mux_even_power_enabled,
471      "device: even mux rail should remain off for odd channels");
472    hardware::i2c::clearSelection();
473  
474    hardware::i2c::accessTopology(&snapshot);
475    TEST_ASSERT_FALSE_MESSAGE(snapshot.mux_even_power_enabled,
476      "device: even mux rail should be off after clearSelection");
477    TEST_ASSERT_FALSE_MESSAGE(snapshot.mux_odd_power_enabled,
478      "device: odd mux rail should be off after clearSelection");
479  }
480  
481  static void test_i2c_mux_scan(void) {
482    WHEN("all I2C buses and mux channels are scanned");
483    hardware::i2c::initialize();
484    hardware::i2c::runDiscovery();
485  
486    hardware::i2c::DiscoveredDevice devices[hardware::i2c::MAX_DISCOVERED_DEVICES];
487    size_t count = hardware::i2c::discoverAll(devices, hardware::i2c::MAX_DISCOVERED_DEVICES);
488  
489    TEST_ASSERT_GREATER_THAN_MESSAGE(0, (int)count, "device: no I2C devices found");
490  
491    char line[120];
492    // TEST_MESSAGE escapes non-ASCII, so ASCII-only tree characters
493    TEST_MESSAGE("");
494    snprintf(line, sizeof(line), "ESP32-S3");
495    TEST_MESSAGE(line);
496  
497    bool has_bus1 = false;
498    for (size_t i = 0; i < count; i++) {
499      if (devices[i].bus == 1) { has_bus1 = true; break; }
500    }
501  
502    //------------------------------------------
503    //  Bus 0
504    //------------------------------------------
505    const char *bus0_branch = has_bus1 ? "+" : "\\";
506    snprintf(line, sizeof(line), "%s-- I2C Bus 0 (GPIO %d/%d)",
507             bus0_branch, config::i2c::BUS_0.sda_gpio, config::i2c::BUS_0.scl_gpio);
508    TEST_MESSAGE(line);
509  
510    const char *bus0_cont = has_bus1 ? "|" : " ";
511    for (size_t i = 0; i < count; i++) {
512      if (devices[i].bus != 0 || devices[i].mux_channel >= 0) continue;
513      snprintf(line, sizeof(line), "%s   \\-- 0x%02X %s",
514               bus0_cont, devices[i].address,
515               hardware::i2c::deviceNameAt(devices[i].address, devices[i].mux_channel));
516      TEST_MESSAGE(line);
517    }
518  
519    if (!has_bus1) return;
520  
521    TEST_MESSAGE(bus0_cont);
522  
523    //------------------------------------------
524    //  Bus 1
525    //------------------------------------------
526    snprintf(line, sizeof(line), "\\-- I2C Bus 1 (GPIO %d/%d)",
527             config::i2c::BUS_1.sda_gpio, config::i2c::BUS_1.scl_gpio);
528    TEST_MESSAGE(line);
529  
530    // Bus 1 unmuxed devices
531    for (size_t i = 0; i < count; i++) {
532      if (devices[i].bus != 1 || devices[i].mux_channel >= 0) continue;
533      if (devices[i].address == config::i2c::MUX_ADDR) continue;
534      snprintf(line, sizeof(line), "    +-- 0x%02X %s",
535               devices[i].address,
536               hardware::i2c::deviceNameAt(devices[i].address, devices[i].mux_channel));
537      TEST_MESSAGE(line);
538    }
539  
540    // Mux header
541    TEST_MESSAGE("    |");
542    snprintf(line, sizeof(line), "    \\-- 0x%02X %s",
543             config::i2c::MUX_ADDR,
544             hardware::i2c::deviceNameAt(config::i2c::MUX_ADDR, -1));
545    TEST_MESSAGE(line);
546  
547    //------------------------------------------
548    //  Mux channels
549    //------------------------------------------
550    for (int channel = 0; channel < 8; channel++) {
551      struct { uint8_t addr; const char *name; } channel_devices[8];
552      int channel_count = 0;
553  
554      for (size_t i = 0; i < count && channel_count < 8; i++) {
555        if (devices[i].bus != 1 || devices[i].mux_channel != channel) continue;
556        if (devices[i].address == 0x50) continue;
557        channel_devices[channel_count].addr = devices[i].address;
558        channel_devices[channel_count].name = hardware::i2c::deviceNameAt(devices[i].address, devices[i].mux_channel);
559        channel_count++;
560      }
561  
562      if (channel_count == 0) continue;
563  
564      bool is_last_channel = true;
565      for (int future = channel + 1; future < 8; future++) {
566        for (size_t i = 0; i < count; i++) {
567          if (devices[i].bus == 1 && devices[i].mux_channel == future && devices[i].address != 0x50) {
568            is_last_channel = false;
569            break;
570          }
571        }
572        if (!is_last_channel) break;
573      }
574  
575      const char *branch = is_last_channel ? "\\" : "+";
576      const char *cont   = is_last_channel ? " " : "|";
577  
578      snprintf(line, sizeof(line), "        %s-- %d: 0x%02X %s",
579               branch, channel,
580               channel_devices[0].addr, channel_devices[0].name);
581      TEST_MESSAGE(line);
582  
583      for (int d = 1; d < channel_count; d++) {
584        snprintf(line, sizeof(line), "        %s       0x%02X %s",
585                 cont,
586                 channel_devices[d].addr, channel_devices[d].name);
587        TEST_MESSAGE(line);
588      }
589    }
590  }
591  
592  static void test_i2c_mux_disable_all_clears_mask(void) {
593    GIVEN("Wire1 is available");
594    test_ensure_wire1();
595    hardware::i2c::TopologySnapshot snapshot = {};
596    hardware::i2c::accessTopology(&snapshot);
597    if (!snapshot.mux_present) {
598      TEST_IGNORE_MESSAGE("mux not present on this board");
599      return;
600    }
601  
602    hardware::i2c::mux.enableChannel(0);
603    hardware::i2c::mux.enableChannel(3);
604    hardware::i2c::mux.enableChannel(7);
605    WHEN("all channels are disabled");
606    TEST_ASSERT_BITS_HIGH_MESSAGE(0x89, hardware::i2c::mux.getChannelMask(),
607      "device: channels 0, 3, 7 should all be enabled");
608  
609    hardware::i2c::mux.disableAllChannels();
610    TEST_ASSERT_EQUAL_HEX8_MESSAGE(0x00, hardware::i2c::mux.getChannelMask(),
611      "device: mask should be 0x00 after disableAllChannels");
612  
613  }
614  
615  static void test_i2c_mux_enable_disable_roundtrip(void) {
616    GIVEN("Wire1 is available");
617    test_ensure_wire1();
618    hardware::i2c::TopologySnapshot snapshot = {};
619    hardware::i2c::accessTopology(&snapshot);
620    if (!snapshot.mux_present) {
621      TEST_IGNORE_MESSAGE("mux not present on this board");
622      return;
623    }
624    hardware::i2c::mux.disableAllChannels();
625  
626    WHEN("a channel is enabled then disabled");
627    hardware::i2c::mux.enableChannel(2);
628    TEST_ASSERT_BIT_HIGH_MESSAGE(2, hardware::i2c::mux.getChannelMask(),
629      "device: bit 2 should be high after enableChannel(2)");
630  
631    hardware::i2c::mux.disableChannel(2);
632    TEST_ASSERT_BIT_LOW_MESSAGE(2, hardware::i2c::mux.getChannelMask(),
633      "device: bit 2 should be low after disableChannel(2)");
634    TEST_ASSERT_EQUAL_HEX8_MESSAGE(0x00, hardware::i2c::mux.getChannelMask(),
635      "device: full mask should be 0x00 after disable");
636  
637  }
638  
639  void hardware::i2c::test() {
640    MODULE("I2C");
641    RUN_TEST(test_i2c_mux_init);
642    RUN_TEST(test_i2c_mux_is_connected);
643    RUN_TEST(test_i2c_mux_channel_count);
644    RUN_TEST(test_i2c_mux_select_and_verify);
645    RUN_TEST(test_i2c_mux_scan);
646    RUN_TEST(test_i2c_mux_channel_power_mapping);
647    RUN_TEST(test_i2c_mux_disable_all_clears_mask);
648    RUN_TEST(test_i2c_mux_enable_disable_roundtrip);
649  }
650  
651  #endif