Source code for QC.SCAN
1"""I2C multiplexer scanner for detecting sensors across all MUX channels.
2
3Iterates through all four I2C MUX channels, runs ``i2cdetect`` on each,
4and cross-references discovered addresses against a known sensor map
5(:obj:`sensorList`) to produce a summary table showing sensor names,
6expected vs. actual channels, and detection status. This is primarily a
7factory-floor diagnostic helper used to confirm that every I2C peripheral
8on an Oizom mainboard is responding on the correct MUX leg.
9
10Example:
11 Run directly from the project root to perform a full bus sweep::
12
13 $ python -m QC.SCAN # doctest: +SKIP
14
15Note:
16 Requires the ``i2cdetect`` utility (``i2c-tools``) and read access
17 to ``/dev/i2c-*``. Switching MUX channels uses
18 :meth:`drivers.gpio.gpio.select_I2C`, which drives MUX-select GPIOs
19 on the host Raspberry Pi.
20"""
21
22import subprocess
23
24from drivers.gpio import gpio
25
26sensorList = {
27 "0x29_1": "0x29 - Light Sensor(TSL2591)",
28 "0x31_0": "0x31 - Co2(ELT)",
29 "0x36_3": "0x36 - Battery",
30 "0x41_2": "0x41 - ATHP SELECTOR",
31 "0x44_2": "0x44 - ATH/ATHP/SHT",
32 "0x53_1": "0x53 - UV Sensor(LTR390)",
33 "0x60_1": "0x60 - OLD UV(SI1147)",
34 "0x60_2": "0x60 - ATHP Pressure",
35 "0x61_0": "0x61 - 4-20mA Current",
36 "0x62_0": "0x62 - CO2(Sensirion)",
37 "0x76_0": "0x76 - Box Temp(BME280)",
38 "0x77_0": "0x77 - Box Temp(BME280)",
39}
40
41
[docs]
42def detect_i2c_addresses(bus_number: int = 0) -> list[int]:
43 """Detect all responding I2C addresses on the specified bus.
44
45 Shells out to ``i2cdetect -y <bus_number>`` and parses the resulting
46 grid into a list of integers. Cells marked ``--`` are skipped, and
47 only acknowledged addresses are returned.
48
49 Args:
50 bus_number: I2C bus number passed to ``i2cdetect -y``. Defaults
51 to ``0`` which corresponds to ``/dev/i2c-0`` on the Pi.
52
53 Returns:
54 List of integer I2C addresses (7-bit) that acknowledged during
55 the scan. Returns an empty list if ``i2cdetect`` fails.
56
57 Raises:
58 subprocess.CalledProcessError: Caught internally and logged; the
59 function returns ``[]`` in that case rather than re-raising.
60
61 Example:
62 Sweep bus 0 and print the discovered addresses in hex::
63
64 >>> for addr in detect_i2c_addresses(0): # doctest: +SKIP
65 ... print(hex(addr))
66
67 Note:
68 Requires ``i2c-tools`` to be installed on the host. The current
69 process must also have permission to read ``/dev/i2c-<bus>``,
70 which usually means membership in the ``i2c`` group or running
71 as root.
72 """
73 try:
74 output = subprocess.check_output(["i2cdetect", "-y", str(bus_number)], universal_newlines=True)
75 addresses = []
76 lines = output.split("\n")[1:] # Skip the header row
77 for line in lines:
78 parts = line.split()
79 if parts:
80 for part in parts[1:]: # Skip the first column which is the row header
81 if part != "--":
82 addresses.append(int(part, 16))
83 return addresses
84 except subprocess.CalledProcessError as e:
85 print(f"An error occurred while detecting I2C addresses: {e}")
86 return []
87
88
[docs]
89def scan_i2c_mux() -> None:
90 """Scan all four I2C MUX channels and print a sensor detection report.
91
92 For each of the four MUX channels (0-3) this routine switches the
93 multiplexer via :meth:`drivers.gpio.gpio.select_I2C`, calls
94 :func:`detect_i2c_addresses` to enumerate live peripherals, and
95 finally compares the results against :obj:`sensorList`. A formatted
96 plain-text table is printed to stdout summarising channel, address,
97 sensor name, and a ``Status`` column that flags expected hits,
98 relocations, and unknown devices.
99
100 Returns:
101 None. Output is rendered to stdout for human inspection.
102
103 Raises:
104 OSError: If switching MUX channels via GPIO fails (propagated
105 from :class:`drivers.gpio.gpio`).
106
107 Example:
108 Run a full scan from a Python REPL on the device::
109
110 >>> scan_i2c_mux() # doctest: +SKIP
111
112 Note:
113 This function is hardware-only and will not produce meaningful
114 output when executed on a development workstation. Must be run
115 with sufficient privileges to manipulate GPIO and the I2C bus.
116 """
117 # Create a mapping from address to expected channel and sensor name
118 address_to_info = {}
119 for key, value in sensorList.items():
120 addr, ch = key.split("_")
121 address_to_info[addr] = {"channel": int(ch), "name": value}
122
123 all_addresses = {}
124 for channel in range(4):
125 gpio.select_I2C(channel)
126 addresses = detect_i2c_addresses()
127 all_addresses[channel] = addresses
128
129 # Collect data for table
130 table_data = []
131 for channel in range(4):
132 addresses = all_addresses[channel]
133 for num in addresses:
134 addr_hex = hex(num)
135 key = f"{addr_hex}_{channel}"
136 if key in sensorList:
137 status = "OK"
138 name = sensorList[key].split(" - ", 1)[1] if " - " in sensorList[key] else sensorList[key]
139 elif addr_hex in address_to_info:
140 expected_ch = address_to_info[addr_hex]["channel"]
141 name = (
142 address_to_info[addr_hex]["name"].split(" - ", 1)[1]
143 if " - " in address_to_info[addr_hex]["name"]
144 else address_to_info[addr_hex]["name"]
145 )
146 status = f"Position changed from {expected_ch} to {channel}"
147 else:
148 name = f"Unknown sensor at {addr_hex}"
149 status = "Unknown"
150 table_data.append([channel, addr_hex, name, status])
151
152 # Print table header
153 print("\nI2C Mux Scan Results")
154 print("=" * 80)
155 print(f"{'Channel':<8} | {'Address':<8} | {'Sensor':<30} | {'Status'}")
156 print("-" * 80)
157
158 # Print table rows
159 current_channel = 0
160 for row in table_data:
161 channel, addr, sensor, status = row
162 if current_channel != channel:
163 print("-" * 80)
164 current_channel = channel
165 print(f"{channel:<8} | {addr:<8} | {sensor:<30} | {status}")
166
167 print("=" * 80)
168 print("Scan complete.\n")
169
170
171if __name__ == "__main__":
172 scan_i2c_mux()