diff --git a/.agents/skills/add-component-v1/SKILL.md b/.agents/skills/add-component-v1/SKILL.md new file mode 100644 index 00000000..6a5ce973 --- /dev/null +++ b/.agents/skills/add-component-v1/SKILL.md @@ -0,0 +1,596 @@ +--- +name: add-component-v1 +description: > + Adds a new I2C sensor component definition to the Wippersnapper_Components repository. + Use this skill whenever the user wants to create a matching PR from the firmware repo, + add a new sensor component, create a definition.json, add an image for a WipperSnapper + I2C component, or even just mentions a sensor name in the context of WipperSnapper or + Adafruit IO. Also use when the user says "add component", "matching PR", "new sensor", + "component definition", or asks about what subcomponents or I2C addresses a sensor uses. + Covers: research, definition.json creation, product image acquisition, CI + validation, and PR creation. This skill is for the components repo only — firmware driver + changes belong in Adafruit_Wippersnapper_Arduino (see the add-sensor-component-v1 skill there). +--- + +# Add I2C Sensor Component Definition to Wippersnapper_Components v1 + +This skill adds a new I2C sensor component to the `Wippersnapper_Components` repository. +Each component lives in `components/i2c//` and consists of: + +1. `definition.json` — describes the sensor, its I2C addresses, and subcomponents +2. `image.jpg` (or `.png`) — product photo, 400x300px, 4:3 ratio, under 100KB + +### Naming convention + +- **lowercase** for the component folder name and all file names within it +- The folder name must exactly match the `strcmp()` string used in the firmware's + `WipperSnapper_I2C.cpp` `initI2CDevice()` method +- Example: `components/i2c/tmp119/` + +Decide the canonical name in Step 0 and use it everywhere. + +## Reference + +The official Adafruit guide for this process: +- Human-readable: https://learn.adafruit.com/how-to-add-a-new-component-to-adafruit-io-wippersnapper?view=all +- Machine-readable: https://learn.adafruit.com/how-to-add-a-new-component-to-adafruit-io-wippersnapper.md?view=all + +Fetch the `.md?view=all` version if you need more detail on any step. + +## Arguments + +Accepts a sensor name as argument (e.g. `/add-component-v1 TMP119`). + +The user may also provide: datasheet URL, product page URL, learn guide URL, vendor name, +I2C addresses, and what the sensor measures. If not provided, research them. + +The user may also reference a companion firmware PR that contains a suggested `definition.json`. +**Treat PR-provided definitions as hints, not as verified data.** Always run Step 0 research +independently — firmware PR authors often use GitHub library URLs instead of learn guides, +miss I2C addresses, or use suboptimal field values. Every field must be verified against +authoritative sources (product page, learn guide, datasheet) before use. + +## Step 0 — Research the Sensor (MANDATORY before creating any files) + +> **Do not skip this step.** Even if a firmware PR or the user supplies a complete +> `definition.json`, you MUST independently verify every field — especially `documentationURL` +> (must be a learn guide for Adafruit products, not a GitHub repo) and `i2cAddresses` (must +> include all ADDR pin combinations). Copying unverified values from PRs has caused bad PRs. + +### Check for duplicates first + +Before anything else, check `components/i2c/` to see if a component already exists for this +sensor. If it does, tell the user — they may want to update it rather than create a new one. + +### Gather sensor information + +| What | How to find it | +|------|---------------| +| **Product page** | Web search "adafruit \" to find `https://www.adafruit.com/product/`. For non-Adafruit products, find the manufacturer's product page or a distributor listing (DigiKey, Mouser, etc.) | +| **Learn guide** | Found on the Adafruit product page HTML (grep for `learn.adafruit.com`). Fetch the `.md?view=all` version for easy reading. For non-Adafruit products, use the manufacturer's documentation or datasheet | +| **Vendor** | The chip manufacturer (e.g. "Bosch", "Texas Instruments", "Sensirion", "OMRON", "Melexis"), NOT "Adafruit" unless Adafruit designed the chip | +| **I2C addresses** | Datasheet, learn guide, or Arduino library. Include ALL possible addresses from ADDR pin configurations. Also check https://learn.adafruit.com/i2c-addresses/the-list | +| **What it measures** | Map each reading to a subcomponent type from the reference table below | +| **Documentation URL** | Prefer: Adafruit learn guide > manufacturer docs page/wiki > datasheet URL | +| **Closest existing component** | Browse `components/i2c/` for a sensor in the same family or with identical reading types — use it as a template | + +### Valid subcomponent types + +These are the only valid values (must match the pattern in `components/i2c/schema.json`): + +| Subcomponent | What it measures | SI unit | +|---|---|---| +| `ambient-temp` | Ambient temperature | degC | +| `ambient-temp-fahrenheit` | Ambient temperature | degF | +| `object-temp` | Object/thermocouple temp | degC | +| `object-temp-fahrenheit` | Object/thermocouple temp | degF | +| `humidity` | Relative humidity | %RH | +| `pressure` | Barometric pressure | hPa | +| `altitude` | Relative altitude | m | +| `co2` | CO2 concentration | ppm | +| `eco2` | Estimated CO2 | ppm | +| `tvoc` | Total VOCs | ppb | +| `gas-resistance` | Gas resistance | ohm | +| `light` | Light level | lux | +| `lux` | Light level (lux) | lux | +| `proximity` | Proximity | unitless | +| `voltage` | Voltage | V | +| `current` | Current | A | +| `color` | Color | unitless | +| `raw` | Raw data | unitless | +| `unitless-percent` | Percentage | % | +| `pm10-std` | PM1.0 standard | ug/m3 | +| `pm25-std` | PM2.5 standard | ug/m3 | +| `pm100-std` | PM10.0 standard | ug/m3 | +| `pm10-env` | PM1.0 environmental | ug/m3 | +| `pm25-env` | PM2.5 environmental | ug/m3 | +| `pm100-env` | PM10.0 environmental | ug/m3 | +| `accelerometer` | Acceleration | m/s2 | +| `magnetic-field` | Magnetic field | uT | +| `orientation` | Orientation | degrees | +| `gyroscope` | Angular velocity | rad/s | +| `gravity` | Gravity | m/s2 | +| `acceleration` | Linear acceleration | m/s2 | +| `rotation` | Rotation | rad/s | +| `voc-index` | VOC index | unitless | +| `nox-index` | NOx index | unitless | + +**Temperature sensors** almost always include BOTH `ambient-temp` AND `ambient-temp-fahrenheit`. +The firmware base class handles the degC-to-degF conversion automatically — the driver only +implements the Celsius version. + +## Step 1 — Create the component folder and definition.json + +### Folder naming + +- **Lowercase only** — CI rejects uppercase characters in paths +- Folder name must match the `strcmp()` string used in the firmware's `WipperSnapper_I2C.cpp` +- Example: `components/i2c/tmp119/` + +### definition.json format + +```json +{ + "displayName": "", + "vendor": "", + "productURL": "https://www.adafruit.com/product/", + "documentationURL": "https://learn.adafruit.com/", + "published": false, + "i2cAddresses": ["0x48"], + "subcomponents": ["ambient-temp", "ambient-temp-fahrenheit"] +} +``` + +#### Field rules + +| Field | Required | Notes | +|-------|----------|-------| +| `displayName` | Yes | 3-24 characters. Usually the chip name (e.g. "TMP119", "BME280") | +| `vendor` | Yes | 3-24 characters. The chip manufacturer, not "Adafruit" | +| `i2cAddresses` | Yes | Array of hex strings. Include ALL possible addresses | +| `subcomponents` | Yes | Array of sensor type strings or objects | +| `productURL` | No | URI to product page (see URL guidance below) | +| `documentationURL` | No | URI to documentation (see URL guidance below) | +| `published` | No | Always set to `false` for new contributions. Adafruit sets `true` after release | +| `description` | No | 3-255 characters. Brief description of capabilities | + +#### URL guidance + +**`productURL`** — where someone can buy or learn about the product: +- **Adafruit products:** `https://www.adafruit.com/product/` (preferred) +- **Non-Adafruit products:** Use distributor listings (DigiKey, Mouser) or manufacturer product + pages. Examples from existing components: + - DigiKey: `https://www.digikey.com/en/products/detail/...` (used by SEN5x, SHT20, MLX90632) + - Manufacturer: `http://www.aosong.com/en/products-60.html` (used by AHT21) + +**`documentationURL`** — technical docs for the sensor: +- **Adafruit products:** Adafruit learn guide (preferred). Find it on the product page + (search for `learn.adafruit.com`). **Never use a GitHub library repo URL** (e.g. + `github.com/adafruit/Adafruit_`) — that is source code, not documentation. +- **Non-Adafruit products:** Manufacturer documentation page, wiki, or datasheet PDF URL. + Examples: Sensirion datasheet PDFs, OMRON user manuals +- Third-party domain URLs may initially fail CI URL validation until a maintainer adds the + domain to the allowlist — note this in the PR if using a non-standard domain + +#### Subcomponent formats + +**Simple format** — when the sensor type name is self-explanatory: +```json +"subcomponents": ["ambient-temp", "ambient-temp-fahrenheit", "pressure"] +``` + +**Object format** — when a custom display name is needed for clarity: +```json +"subcomponents": [ + { "displayName": "Ambient Light", "sensorType": "light" }, + { "displayName": "UV Count", "sensorType": "raw" } +] +``` + +Use objects when: +1. **Type name is ambiguous** — "light" could mean visible, UV, or IR. `displayName` clarifies + in the UI +2. **Two readings share the same physical type** — v1 schema forbids duplicate `sensorType`. + Use `"raw"` for the second with a descriptive `displayName` (e.g. LTR-329: `"light"` for + ambient, `"raw"` with `displayName: "Infrared"` for IR) +3. **Non-standard units** — sensor reports in a unit that doesn't match the Adafruit_Sensor SI + unit for that type; use `raw` or `unitless-percent` with a descriptive `displayName` +4. **Clarity** — when the auto-generated UI label would be confusing. Compare existing components + that use the same types for reference + +When using `"raw"` as a stand-in, the driver must implement `getEventRaw()` for that reading. + +**Examples from existing components:** +- **TMP117** (temp only): `["ambient-temp", "ambient-temp-fahrenheit"]` +- **BME280** (multi): `["ambient-temp", "ambient-temp-fahrenheit", "humidity", "pressure", "altitude"]` +- **SGP41** (VOC + NOx + raw): `["voc-index", "nox-index", {"displayName": "Raw VOC Ticks (Reference)", "sensorType": "raw"}]` +- **D6T-1A** (ambient + object temp, all objects for clarity): + ```json + [ + {"displayName": "Ambient Temperature (°C)", "sensorType": "ambient-temp"}, + {"displayName": "Ambient Temperature (°F)", "sensorType": "ambient-temp-fahrenheit"}, + {"displayName": "Measured Object Temp (°C)", "sensorType": "object-temp"}, + {"displayName": "Measured Object Temp (°F)", "sensorType": "object-temp-fahrenheit"} + ] + ``` +- **SEN55** (many readings, simple strings): `["ambient-temp", "ambient-temp-fahrenheit", "humidity", "pm10-std", "pm25-std", "pm100-std", "voc-index", "nox-index"]` + +### Schema validation + +The definition.json must validate against `components/i2c/schema.json`. Key constraints: +- `displayName`: string, 3-24 chars +- `vendor`: string, 3-24 chars +- `i2cAddresses`: array of strings +- `subcomponents`: array — each item is either a valid sensor type string OR an object with + required `displayName` (string) and `sensorType` (valid sensor type string), optional `defaultPeriod` +- No additional properties allowed (`additionalProperties: false`) + +## Step 2 — Add product image + +### Requirements (enforced by CI) + +| Property | Requirement | +|----------|-------------| +| Dimensions | Exactly 400x300 pixels | +| Aspect ratio | Must be exactly 4:3 | +| File size | Under 100KB (102400 bytes) | +| Format | `.jpg`, `.jpeg`, `.png`, `.gif`, or `.svg` | +| Filename | `image.` (lowercase) | +| Mimetype | Must match file extension | + +### Choosing the right source image + +**For Adafruit products:** + +1. **Scrape all image URLs from the product page:** Fetch `https://www.adafruit.com/product/` + and extract all `cdn-shop.adafruit.com` image URLs. The product page gallery contains every + available shot — don't guess image numbers, as they are not necessarily contiguous. +2. **CRITICAL — Always use the `/original/` CDN URL, NOT `970x728` or `640x480`.** + The Adafruit CDN URL contains a resolution prefix. You MUST replace it with `original` + to get the highest-resolution source for downscaling. Downloading a pre-scaled version + (e.g. `970x728`) produces a blurry 400x300 result. + - **USE THIS:** `https://cdn-shop.adafruit.com/original/-NN.jpg` + - NOT: `https://cdn-shop.adafruit.com/970x728/-NN.jpg` + - NOT: `https://cdn-shop.adafruit.com/640x480/-NN.jpg` + - For each image URL scraped from the product page, replace the resolution part + (e.g. `970x728`, `640x480`) with `original` before downloading +3. **Check all available shots** from the scraped URLs — pick the slight-angle / isometric + close-up of the breakout board with a plain background (this is the standard Adafruit product + photo style, typically image `00`). Avoid lifestyle shots, shots with other boards or + accessories in the frame, or images where the board is very small in the frame. +4. Compare with existing component images (e.g. `components/i2c/sgp41/image.jpg`) for the + expected style: close-up, slight angle, plain dark background, board well-centered. + +**For non-Adafruit products:** + +If no Adafruit CDN image is available, try: +1. The manufacturer's product page for an official product photo +2. Distributor listings (DigiKey, Mouser) which often have product images +3. Ask the user to provide an image + +Whatever the source, the image must meet the same CI requirements (400x300, 4:3, under 100KB). + +### Centering and cropping + +**The board must be visually centered in the final image.** This means: +- The **top** and **bottom** of the board should be approximately the same distance from the + top and bottom edges of the image +- The board should be **horizontally centered** in the frame +- There should be a comfortable amount of dead space around the board so it doesn't feel + cropped or cramped in the UI + +The source image from Adafruit CDN often has the board slightly off-center (typically shifted +upward). To correct this: + +1. **View the source image** to identify the board's position. Estimate the top margin (from + image top to board top) and bottom margin (from board bottom to image bottom). +2. Calculate the vertical shift needed: `shift = (bottom_margin - top_margin) / 2` + - Positive shift = crop from top (board was too high) + - Negative shift = crop from bottom (board was too low) +3. If the board is also off-center horizontally, apply a horizontal offset too. +4. After shifting, restore exact 4:3 ratio by trimming the opposite axis symmetrically. + +The shift value depends on the source image resolution. If you determined a shift of `N` pixels +at one resolution, scale proportionally for a different resolution source: +`scaled_shift = N * source_height / reference_height` + +```python +from PIL import Image + +img = Image.open('source.jpg') +w, h = img.size + +# Vertical shift to center the board (adjust based on visual inspection) +# Positive = crop from top, negative = crop from bottom +v_shift = 75 # example for a ~3000px tall source image + +# Horizontal shift to center the board (usually 0, adjust if needed) +h_shift = 0 # positive = crop from left, negative = crop from right + +# Apply shifts +left = max(0, h_shift) +top = max(0, v_shift) +right = w + min(0, h_shift) +bottom = h + min(0, v_shift) +cropped = img.crop((left, top, right, bottom)) + +# Restore exact 4:3 ratio by trimming the axis that wasn't shifted +cw, ch = cropped.size +target_w = int(ch * 4 / 3) +target_w = target_w - (target_w % 2) # ensure even +if target_w > cw: + # Width is limiting — trim height instead + target_h = int(cw * 3 / 4) + target_h = target_h - (target_h % 2) + y_off = (ch - target_h) // 2 + final = cropped.crop((0, y_off, cw, y_off + target_h)) +else: + x_off = (cw - target_w) // 2 + final = cropped.crop((x_off, 0, x_off + target_w, ch)) +``` + +**After processing, visually inspect the result** to confirm the board looks centered. If not, +adjust the shift values and try again. The goal is for someone glancing at the image to see the +board sitting comfortably in the middle of the frame. + +### High-quality resize + +Start from the **original** resolution source to minimize artifacts when downscaling. A larger +source means more pixels to sample from, producing smoother edges and finer detail. + +```python +# LANCZOS is the highest quality downscale filter in PIL +resized = final.resize((400, 300), Image.LANCZOS) + +# Save as high-quality JPEG (will be compressed by mozjpeg next) +resized.save('temp_highquality.jpg', 'JPEG', quality=92, subsampling=0) +``` + +Key points: +- Always use `Image.LANCZOS` (not `BILINEAR` or `NEAREST`) for downscaling +- Use `quality=92` and `subsampling=0` (4:4:4 chroma) for the intermediate JPEG — this + preserves detail for mozjpeg to work with +- Starting from the `original` CDN URL (often 3000+ pixels) produces far cleaner results than + from `640x480` or even `970x728` +- Never force-resize without cropping to 4:3 first — it stretches the image + +### Compression with mozjpeg + +Use **mozjpeg** (https://github.com/mozilla/mozjpeg) for best JPEG compression. The Python +package `mozjpeg-lossless-optimization` provides lossless re-encoding that typically saves +10-15% over standard JPEG encoders with no quality loss. + +```bash +pip install mozjpeg-lossless-optimization +``` + +```python +import mozjpeg_lossless_optimization as mozjpeg + +with open('temp_highquality.jpg', 'rb') as f: + input_data = f.read() + +optimized = mozjpeg.optimize(input_data) + +with open('components/i2c//image.jpg', 'wb') as f: + f.write(optimized) +``` + +If the result is still over 100KB, reduce the PIL quality setting (try 85, then 80) and +re-run mozjpeg optimization. + +**Fallback** if mozjpeg is unavailable: use PIL directly with `quality=85` — this usually +produces files under 100KB for 400x300 product photos. + +### Complete image processing script + +```python +from PIL import Image +import urllib.request, io, os + +# 1. Download original resolution source (best quality) +PID = '' +IMAGE_NUM = '01' # use image numbers scraped from product page +url = f'https://cdn-shop.adafruit.com/original/{PID}-{IMAGE_NUM}.jpg' +data = urllib.request.urlopen(url).read() +img = Image.open(io.BytesIO(data)) +print(f'Source: {img.size}') + +# 2. Center-crop (adjust shifts based on visual inspection) +w, h = img.size +v_shift = 75 # vertical: positive = crop from top (board was too high) +h_shift = 0 # horizontal: positive = crop from left +left = max(0, h_shift) +top = max(0, v_shift) +right = w + min(0, h_shift) +bottom = h + min(0, v_shift) +cropped = img.crop((left, top, right, bottom)) + +# 3. Restore exact 4:3 ratio +cw, ch = cropped.size +target_w = int(ch * 4 / 3) +target_w = target_w - (target_w % 2) +if target_w > cw: + target_h = int(cw * 3 / 4) + target_h = target_h - (target_h % 2) + y_off = (ch - target_h) // 2 + final = cropped.crop((0, y_off, cw, y_off + target_h)) +else: + x_off = (cw - target_w) // 2 + final = cropped.crop((x_off, 0, x_off + target_w, ch)) + +# 4. High-quality resize from original resolution +resized = final.resize((400, 300), Image.LANCZOS) + +# 5. Save intermediate high-quality JPEG +temp_path = os.path.join(os.path.dirname(__file__) or '.', 'temp_hq.jpg') +resized.save(temp_path, 'JPEG', quality=92, subsampling=0) + +# 6. Optimize with mozjpeg +try: + import mozjpeg_lossless_optimization as mozjpeg + with open(temp_path, 'rb') as f: + optimized = mozjpeg.optimize(f.read()) + output_path = f'components/i2c//image.jpg' + with open(output_path, 'wb') as f: + f.write(optimized) + print(f'Saved: {len(optimized)/1024:.1f} KB') +except ImportError: + print('mozjpeg not available, using PIL fallback') + resized.save(f'components/i2c//image.jpg', 'JPEG', quality=85) + +# 7. Clean up temp file +if os.path.exists(temp_path): + os.remove(temp_path) +``` + +### Platform notes + +- **Windows:** `/tmp` does not exist for native Python. Use `tempfile.gettempdir()` or write + temp files to the working directory and clean up after. When using `python.exe` on Windows, + use forward-slash paths (`C:/dev/...`) — they work in Python on Windows. +- **ImageMagick** may not be installed. Prefer Python PIL/Pillow (`pip install Pillow`). +- Always clean up intermediate files (candidates, test crops, BMPs) — only `definition.json` + and `image.jpg` should remain in the component folder. + +## Step 3 — Validate locally (if possible) + +### JSON schema validation + +```python +# Python validation (cross-platform, no extra tools needed) +python -c " +import json, jsonschema +schema = json.load(open('components/i2c/schema.json')) +defn = json.load(open('components/i2c//definition.json')) +jsonschema.validate(defn, schema) +print('Valid!') +" +``` + +```bash +# Or using ajv-cli if available +ajv validate -s components/i2c/schema.json -d "components/i2c//definition.json" +``` + +### Image validation + +```python +# Python validation (cross-platform) +python -c " +from PIL import Image +import os +img = Image.open('components/i2c//image.jpg') +w, h = img.size +size = os.path.getsize('components/i2c//image.jpg') +ok = w == 400 and h == 300 and size < 102400 +print(f'{w}x{h}, {size/1024:.1f} KB — {\"PASS\" if ok else \"FAIL\"}) +" +``` + +### CI checks that will run on PR + +1. **JSON schema validation** — definition.json against schema.json +2. **Filename validation** — only `definition.json` and `image.(png|jpg|jpeg|gif|svg)` allowed, + all lowercase, under `components///` +3. **Image mimetype** — file extension must match actual mimetype +4. **Image dimensions** — max 400x300, must be exactly 4:3 ratio +5. **Image file size** — under 100KB +6. **URL validation** — productURL and documentationURL must be valid URIs + +## Step 4 — Create PR + +Branch from `main`, commit the component folder, and create a PR. + +**PR title:** `Add component definition` + +**PR body should follow the repo template:** + +```markdown +### Description + + +### Product URL + + +### Product Documentation URL + +``` + +If there is a companion firmware PR in `adafruit/Adafruit_Wippersnapper_Arduino`, reference it +in the description. Mention the AI model used in the PR title and body if known/available. + +--- + +## Worked Example: TMP119 + +### Research +- **Duplicate check:** `components/i2c/tmp119/` does not exist, `tmp117/` does — different chip +- Web search "adafruit TMP119" -> product page https://www.adafruit.com/product/6482 +- Product page links to learn guide: https://learn.adafruit.com/adafruit-tmp119-high-precision-temperature-sensor +- Vendor: Texas Instruments (TMP119 is a TI chip) +- I2C addresses: 0x48, 0x49, 0x4A, 0x4B (from datasheet, same as TMP117) +- Measures: Temperature only -> `ambient-temp` + `ambient-temp-fahrenheit` +- Closest existing component: `components/i2c/tmp117/` + +### Files created + +`components/i2c/tmp119/definition.json`: +```json +{ + "displayName": "TMP119", + "vendor": "Texas Instruments", + "productURL": "https://www.adafruit.com/product/6482", + "documentationURL": "https://learn.adafruit.com/adafruit-tmp119-high-precision-temperature-sensor", + "published": false, + "i2cAddresses": ["0x48", "0x49", "0x4A", "0x4B"], + "subcomponents": ["ambient-temp", "ambient-temp-fahrenheit"] +} +``` + +`components/i2c/tmp119/image.jpg` — 400x300, board centered, mozjpeg-optimized, 45KB. + +### Image processing +- Source: `https://cdn-shop.adafruit.com/original/6482-01.jpg` (3974x3056) +- Board was slightly high in frame — applied v_shift=75 to center vertically +- Resized with LANCZOS from original resolution for clean downscale +- Compressed with mozjpeg lossless optimization + +### Companion PR +Firmware driver PR in `adafruit/Adafruit_Wippersnapper_Arduino` — references this components PR. + +--- + +## Worked Example: D6T-1A (Non-Adafruit Product) + +### Research +- **No Adafruit product page** — this is an OMRON sensor sold via DigiKey +- Vendor: OMRON +- I2C address: 0x0A (fixed, no ADDR pin) +- Measures: Ambient temp + object temp (non-contact thermal IR) +- Uses object-style subcomponents because it has both ambient and object temperature readings + and the display names add clarity + +### Files created + +`components/i2c/d6t1a/definition.json`: +```json +{ + "displayName": "D6T-1A Thermal Sensor", + "description": "Non-contact MEMS thermal infrared sensor for precise temperature detection (D6T-1A-01/D6T-1A-02)", + "vendor": "OMRON", + "productURL": "https://www.digikey.com/en/products/detail/omron-electronics-inc-emc-div/D6T-1A-02/8602566", + "documentationURL": "https://omronfs.omron.com/en_US/ecb/products/pdf/en_D6T_users_manual.pdf", + "published": true, + "i2cAddresses": ["0x0A"], + "subcomponents": [ + {"displayName": "Ambient Temperature (°C)", "sensorType": "ambient-temp"}, + {"displayName": "Ambient Temperature (°F)", "sensorType": "ambient-temp-fahrenheit"}, + {"displayName": "Measured Object Temp (°C)", "sensorType": "object-temp"}, + {"displayName": "Measured Object Temp (°F)", "sensorType": "object-temp-fahrenheit"} + ] +} +``` + +Note: Image sourced from manufacturer product page since no Adafruit CDN image exists. +Third-party documentation URLs may need domain allowlisting by a maintainer before CI passes. diff --git a/.gitignore b/.gitignore index ad56f2a8..dddf2389 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,7 @@ Icon .AppleDesktop Network Trash Folder Temporary Items -.apdisk \ No newline at end of file +.apdisk +.claude/settings.local.json +.claude/worktrees/ +__pycache__/ diff --git a/README.md b/README.md index 86d5a040..e230be8f 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@ # Wippersnapper Component Definitions -Welcome! This repository contains JSON definition files and images that allow components and sensors to be used with WipperSnapper, Adafruit IO's no-code IoT service. +Welcome! This repository contains JSON definition files and images that allow components and sensors to be used with [WipperSnapper](https://io.adafruit.com/wippersnapper), Adafruit IO's no-code IoT service. [Click here to submit suggestions or requests for new Adafruit.io WipperSnapper components >>>](https://github.com/adafruit/WipperSnapper_Components/issues/new/choose) +## How Does It Work? -## How Will It Work? - -Anyone can add a new component to Wippersnapper by writing a small amount of descriptive JSON and adding an image! If accepted, a supported component will: +Anyone can add a new component to WipperSnapper by writing a small amount of descriptive JSON and adding an image! If accepted, a supported component will: ### Appear in this list @@ -23,139 +22,569 @@ Anyone can add a new component to Wippersnapper by writing a small amount of des ### And work seamlessly with the rest of IO, including Dashboards and Triggers! +## Repository Structure + +``` +components/ +├── pin/ # Digital/analog GPIO components (LEDs, buttons, sensors) +├── i2c/ # I2C sensor breakouts (temperature, pressure, humidity, etc.) +├── i2c_output/ # I2C output displays (OLEDs, character LCDs, 7-segment, etc.) +├── display/ # TFT and E-Ink displays +├── ds18x20/ # 1-Wire DS18x20 temperature sensors +├── pixel/ # Addressable LED strips (NeoPixel, DotStar) +├── pwm/ # PWM-controlled devices (dimmable LEDs, buzzers) +├── servo/ # Servo motor controllers +├── uart/ # UART/serial-based sensors +└── sensors.json # Centralized registry of sensor type definitions +``` + +Each component type directory contains: +- `schema.json` — JSON Schema that validates component definitions of that type. +- One subdirectory per component, each containing: + - `definition.json` — The component definition (validated against the parent `schema.json`). + - An image file (`.jpg`, `.png`, or `.svg`) — A photo or illustration of the component. + ## Contributing [We have a guide on adding components to this repository on the Adafruit Learning System](https://learn.adafruit.com/how-to-add-a-new-component-to-adafruit-io-wippersnapper) -## Pin Component Format - -| | | -|--------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| -| *title* | Pin Component Definition | -| *description* | A pin-based WipperSnapper component for use in Adafruit IO | -| *type* | object | -| *required* | | -| displayName | | -| mode | | -| direction | | -| *additionalProperties* | false | -| *properties* | | -| `displayName` | | -| *description* | The human-friendly name of this component. | -| *type* | string | -| *minLength* | 3 | -| *maxLength* | 24 | -| `mode` | | -| *description* | This component's mode, either DIGITAL or ANALOG. | -| *type* | string | -| *pattern* | `^(DIGITAL\|ANALOG)$` | -| `direction` | | -| *description* | This component's direction, either INPUT or OUTPUT. | -| *type* | string | -| *pattern* | `^(INPUT\|OUTPUT)$` | -| `autoSelectString` | | -| *description* | A hint for automatically looking up pin names that may be appropriate for this kind of component. | -| *type* | string | -| *minLength* | 3 | -| *maxLength* | 24 | -| `selectPullUp` | | -| *description* | If true, the user will be able to select pull up or down options. | -| *type* | boolean | -| `pull` | | -| *description* | This component's pull setting, either UP or DOWN. | -| *type* | string | -| *pattern* | `^(UP\|DOWN)$` | -| `selectReadMode` | | -| *description* | If true, the user will be able to select the read mode between pin and voltage options. | -| *type* | boolean | -| `analogReadMode` | | -| *description* | For ANALOG mode components, specifies whether to read values (PIN_VALUE) or voltages (PIN_VOLTAGE). Will be a default if `selectReadMode` option is true. | -| *type* | string | -| *pattern* | `^(PIN_VALUE\|PIN_VOLTAGE)$` | -| `defaultPeriod` | | -| *description* | If present, the component form will allow the user to set its period, with this value as the default (in seconds) | -| *type* | number | -| *minimum* | 30 | -| *maximum* | 86400 | -| `forceOnPeriod` | | -| *description* | If true, the user must specify a period (won't be optional in the form). | -| *type* | boolean | -| `visualization` | | -| *description* | Specifies which visual component to use in the WipperSnapper interface and how to configure it | -| *type* | object | -| `discriminator` | | -| *propertyName* | type | -| *required* | | -| type | | -| *oneOf* | | - -| *properties* | | *additionalProperties* | -|------------------|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| -| type | | | -| const | switch | false | -| `offLabel` | | false | -| type | string | false | -| `offIcon` | | false | -| type | string | false | -| `onLabel` | | false | -| type | string | false | -| `onIcon` | | false | -| type | string | false | -| type | | false | - -| *properties* | | *additionalProperties* | -|------------------|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| -| type | | false | -| const | button | false | -| `pressedLabel` | | false | -| type | string | false | -| `unpressedLabel` | | false | -| type | string | false | - -| *properties* | | *additionalProperties* | -|------------------|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| -| type | | false | -| const | slider | false | - -## I2C Component Format -todo - -## Legend - -## I2C Sensor Types - -Possible values for an I2C component's subcomponents' `sensorType` field: - -- "unspecified" -- "accelerometer" -- "magnetic-field" -- "orientation" -- "gyroscope" -- "light" -- "pressure" -- "proximity" -- "gravity" -- "acceleration" -- "rotation" -- "humidity" -- "ambient-temp" -- "ambient-temp-fahrenheit" -- "object-temp" -- "object-temp-fahrenheit" -- "voltage" -- "current" -- "color" -- "raw" -- "pm10-std" -- "pm25-std" -- "pm100-std" -- "pm10-env" -- "pm25-env" -- "pm100-env" -- "co2" -- "gas-resistance" -- "altitude" -- "lux" -- "eco2" -- "unitless-percent" -- "tvoc" +### Image Requirements + +Images included with component definitions are validated by CI and must meet these requirements: + +| Requirement | Rule | +|------------------|---------------------------------------------------------| +| **Format** | `.jpg`, `.png`, or `.svg` only | +| **Aspect Ratio** | 4:3 | +| **Max Size** | 400 × 300 pixels | +| **Max File Size**| 100 KB | +| **Filename** | Lowercase only, no uppercase characters | + +### Validation + +All pull requests are automatically validated by CI. The validation pipeline checks: + +1. **Schema validation** — Every `definition.json` is validated against its component type's `schema.json`. +2. **Filename rules** — No uppercase characters in file or directory names. +3. **Image rules** — Format, dimensions, aspect ratio, and file size (see above). +4. **Permission scoping** — External contributors may only add or modify `definition.json` files and images within component subdirectories. Only Adafruit maintainers may modify `schema.json` or `sensors.json`. + +## Common Fields + +All component types share these fields (in addition to their type-specific required fields): + +| Field | Type | Description | +|--------------------|---------|---------------------------------------------------------------------------------------------------------------------| +| `displayName` | string | **(required)** The human-friendly name of this component. 3–24 characters (up to 30 for some types). | +| `vendor` | string | **(required)** Name of the company that makes this component. 3–24 characters. | +| `published` | boolean | If `true`, the component is visible to all users. If `false`, it is hidden behind a developer toggle. | +| `description` | string | A brief description of the component's capabilities. 3–255 characters. | +| `productURL` | string | URL to the component's product page. | +| `documentationURL` | string | URL to the component's documentation. | + +## Component Formats + +### Pin + +Pin components represent digital or analog GPIO-based devices such as LEDs, buttons, and basic sensors. + +**Required fields:** `displayName`, `vendor`, `mode`, `direction` + +| Field | Type | Description | +|-------------------|---------|----------------------------------------------------------------------------------------------------------| +| `mode` | string | `DIGITAL` or `ANALOG`. | +| `direction` | string | `INPUT` or `OUTPUT`. | +| `autoSelectString`| string | A hint for automatically looking up appropriate pin names. 3–24 characters. | +| `selectPullUp` | boolean | If `true`, the user can select pull-up or pull-down options. | +| `pull` | string | Pull setting: `UP` or `DOWN`. | +| `selectReadMode` | boolean | If `true`, the user can select the read mode between pin value and voltage. | +| `analogReadMode` | string | For `ANALOG` mode: `PIN_VALUE` or `PIN_VOLTAGE`. Acts as a default if `selectReadMode` is `true`. | +| `defaultPeriod` | number | Default polling period in seconds (30–86400). If present, the form allows the user to set a period. | +| `forceOnPeriod` | boolean | If `true`, the user must specify a period (it won't be optional). | +| `visualization` | object | Specifies the UI widget. See [Pin Visualization Types](#pin-visualization-types). | + +#### Pin Visualization Types + +The `visualization` object requires a `type` field. Allowed types: + +- **`switch`** — A toggle switch. Optional fields: `offLabel`, `offIcon`, `onLabel`, `onIcon` (strings). +- **`button`** — A push button. Optional fields: `pressedLabel`, `unpressedLabel` (strings). +- **`slider`** — A slider control. No additional fields. + +
+Example: LED (digital output with switch) + +```json +{ + "displayName": "LED", + "vendor": "Generic", + "mode": "DIGITAL", + "direction": "OUTPUT", + "visualization": { + "type": "switch", + "offIcon": "fa6:regular:lightbulb", + "onIcon": "fa6:solid:lightbulb-on" + } +} +``` +
+ +
+Example: Push Button (digital input) + +```json +{ + "displayName": "Push Button", + "vendor": "Generic", + "mode": "DIGITAL", + "direction": "INPUT", + "defaultPeriod": 30, + "selectPullUp": true, + "visualization": { + "type": "button" + } +} +``` +
+ +
+Example: Potentiometer (analog input with slider) + +```json +{ + "displayName": "Potentiometer", + "vendor": "Generic", + "mode": "ANALOG", + "direction": "INPUT", + "defaultPeriod": 60, + "analogReadMode": "PIN_VALUE", + "selectReadMode": true, + "visualization": { + "type": "slider" + } +} +``` +
+ +--- + +### I2C + +I2C components represent sensor breakouts connected via the I2C bus. Each component can expose one or more sensor subcomponents (e.g. a BME280 exposes temperature, humidity, pressure, and altitude). + +**Required fields:** `displayName`, `vendor`, `i2cAddresses`, `subcomponents` + +| Field | Type | Description | +|------------------|--------|--------------------------------------------------------------------------------------------------------| +| `i2cAddresses` | array | List of I2C addresses as hex strings (e.g. `"0x77"`). | +| `subcomponents` | array | List of sensor subcomponents. Each item is either a sensor type string or an object (see below). | + +#### I2C Subcomponent Format + +Each subcomponent can be a simple sensor type string (e.g. `"ambient-temp"`) or an object with: + +| Field | Type | Description | +|----------------|--------|--------------------------------------------------------------------------------| +| `displayName` | string | **(required)** Human-friendly name of the sensor. 3–30 characters. | +| `sensorType` | string | **(required)** One of the supported [Sensor Types](#sensor-types). | +| `type` | string | A unique lookup string for this sensor and its parent component. 3–24 chars. | +| `defaultPeriod`| number | Default polling period in seconds (1–86400). | + +
+Example: BME280 (multiple subcomponents) + +```json +{ + "displayName": "BME280", + "vendor": "Bosch", + "i2cAddresses": ["0x77", "0x76"], + "subcomponents": [ + "ambient-temp", + "ambient-temp-fahrenheit", + "humidity", + "pressure", + "altitude" + ] +} +``` +
+ +
+Example: SEN55 (subcomponents as objects with custom display names) + +```json +{ + "displayName": "SEN55", + "vendor": "Sensirion", + "i2cAddresses": ["0x69"], + "subcomponents": [ + { "displayName": "Temperature", "sensorType": "ambient-temp" }, + { "displayName": "Temperature (°F)", "sensorType": "ambient-temp-fahrenheit" }, + { "displayName": "Humidity", "sensorType": "humidity" }, + { "displayName": "PM1.0 Std", "sensorType": "pm10-std" }, + { "displayName": "PM2.5 Std", "sensorType": "pm25-std" }, + { "displayName": "PM10.0 Std", "sensorType": "pm100-std" }, + { "displayName": "VOC Index", "sensorType": "voc-index" }, + { "displayName": "NOx Index", "sensorType": "nox-index" } + ] +} +``` +
+ +--- + +### I2C Output + +I2C output components represent I2C-connected display devices such as character LCDs, 7-segment displays, alphanumeric displays, and OLEDs. + +**Required fields:** `displayName`, `vendor`, `outputType`, `i2cAddresses` + +| Field | Type | Description | +|------------------------|--------|-------------------------------------------------------------------------------------| +| `i2cAddresses` | array | List of supported I2C addresses as hex strings (e.g. `"0x3C"`). | +| `outputType` | string | One of: `CHARLCD`, `7SEG`, `ALPHANUM`, `OLED`. | +| `charLcdColumns` | number | Number of columns for a character LCD (0–128). Used when `outputType` is `CHARLCD`. | +| `charLcdRows` | number | Number of rows for a character LCD (0–16). Used when `outputType` is `CHARLCD`. | +| `ledBackpackAlignment` | string | Text alignment for LED backpack displays: `LEFT` or `RIGHT`. | +| `ledBackpackBrightness`| number | Brightness level for LED backpack displays (0–15). | +| `oledWidth` | number | OLED display width in pixels (0–128). Used when `outputType` is `OLED`. | +| `oledHeight` | number | OLED display height in pixels (0–64). Used when `outputType` is `OLED`. | +| `textSize` | string | Font size for OLED displays: `SZ_DEFAULT` or `SZ_LARGE`. | + +
+Example: 16x2 Character LCD + +```json +{ + "displayName": "16x2 Character Display", + "vendor": "Adafruit", + "i2cAddresses": ["0x20", "0x21", "0x22", "0x23", "0x24", "0x25", "0x26", "0x27"], + "outputType": "CHARLCD", + "charLcdColumns": 16, + "charLcdRows": 2 +} +``` +
+ +
+Example: 128x64 OLED + +```json +{ + "displayName": "128x64 OLED (Default)", + "vendor": "Adafruit", + "i2cAddresses": ["0x3D", "0x3C"], + "outputType": "OLED", + "oledWidth": 128, + "oledHeight": 64, + "textSize": "SZ_DEFAULT" +} +``` +
+ +--- + +### Display + +Display components represent SPI-connected TFT and E-Ink (EPD) screens. + +**Required fields:** `displayName`, `vendor`, `displayType` + +The `displayType` object is required and contains: + +| Field | Type | Description | +|-------------|--------|--------------------------------------------------------------------------------| +| `type` | string | **(required)** Display type: `epd` (E-Paper) or `tft`. | +| `driver` | string | **(required)** Driver: `epd_ssd1680`, `epd_ili0373`, `tft_st7789`, or `unspecified`. | +| `spiEpd` | object | Optional SPI bus config for EPD displays (`{ "bus": }`). | +| `spiTft` | object | Optional SPI bus config for TFT displays (`{ "bus": }`). | +| `epdConfig` | object | EPD-specific config (see below). Required fields: `mode`, `width`, `height`, `textSize`. | +| `tftConfig` | object | TFT-specific config (see below). Required fields: `width`, `height`, `rotation`, `textSize`. | + +**EPD config (`epdConfig`):** + +| Field | Type | Description | +|------------|---------|---------------------------------------------------------| +| `mode` | string | Display mode: `unspecified`, `grayscale4`, or `mono`. | +| `width` | integer | Display width in pixels (min 1). | +| `height` | integer | Display height in pixels (min 1). | +| `textSize` | integer | Default text size (scale of 8×5 px font). | + +**TFT config (`tftConfig`):** + +| Field | Type | Description | +|------------|---------|---------------------------------------------------------| +| `width` | integer | Display width in pixels (min 1). | +| `height` | integer | Display height in pixels (min 1). | +| `rotation` | integer | Screen rotation: `0`, `1`, `2`, or `3`. | +| `textSize` | integer | Default text size (scale of 8×5 px font). | + +
+Example: TFT display + +```json +{ + "displayName": "1.14\" TFT LCD Display", + "vendor": "Adafruit", + "description": "Adafruit 1.14\" 240x135 Color TFT Display + MicroSD Card Breakout - ST7789", + "displayType": { + "type": "tft", + "driver": "tft_st7789", + "tftConfig": { + "height": 240, + "width": 135, + "rotation": 3, + "textSize": 3 + } + } +} +``` +
+ +
+Example: E-Ink display + +```json +{ + "displayName": "MagTag - 2.9\" E-Ink Display", + "vendor": "Adafruit", + "description": "Adds the Adafruit MagTag's 2.9\" E-Ink display to your project.", + "displayType": { + "type": "epd", + "driver": "unspecified", + "epdConfig": { + "mode": "unspecified", + "width": 296, + "height": 128, + "textSize": 3 + } + } +} +``` +
+ +--- + +### DS18x20 + +DS18x20 components represent 1-Wire temperature sensors (e.g. DS18B20). + +**Required fields:** `displayName`, `vendor`, `subcomponents`, `sensorResolution` + +| Field | Type | Description | +|--------------------|--------|------------------------------------------------------------------------------------| +| `subcomponents` | array | Temperature sensor types. Each item must match `ambient-temp` or `ambient-temp-fahrenheit`. | +| `sensorResolution` | number | Desired sensor read resolution in bits (9–12). | + +
+Example: DS18B20 + +```json +{ + "displayName": "DS18B20", + "vendor": "Generic", + "subcomponents": ["ambient-temp", "ambient-temp-fahrenheit"], + "sensorResolution": 12 +} +``` +
+ +--- + +### Pixel + +Pixel components represent addressable LED strips such as NeoPixels and DotStars. + +**Required fields:** `displayName`, `vendor`, `pixelsType`, `defaultPixelsOrder` + +| Field | Type | Description | +|---------------------|--------|--------------------------------------------------------------------| +| `pixelsType` | string | `NEOPIXEL` or `DOTSTAR`. | +| `defaultPixelsOrder`| string | Color ordering: `GRB`, `GRBW`, `RGB`, `RGBW`, or `BRG`. | +| `autoSelectString` | string | Pin name hint for automatic selection. 3–24 characters. | + +
+Example: NeoPixel + +```json +{ + "displayName": "NeoPixel", + "vendor": "Generic", + "pixelsType": "NEOPIXEL", + "defaultPixelsOrder": "GRB", + "autoSelectString": "neopixel" +} +``` +
+ +--- + +### PWM + +PWM components represent pulse-width-modulation-controlled devices such as dimmable LEDs, RGB LEDs, and buzzers. + +**Required fields:** `displayName`, `vendor`, `pwmSetting` + +| Field | Type | Description | +|-----------------|--------|-------------------------------------------------------------------------------------------------| +| `pwmSetting` | string | `fixedFrequency` (variable duty cycle) or `variableFrequency` (fixed duty cycle). | +| `visualization` | object | Specifies the UI widget. See [PWM Visualization Types](#pwm-visualization-types). | + +#### PWM Visualization Types + +- **`switch-pwm`** — A toggle switch. Optional fields: `offLabel`, `offIcon`, `onLabel`, `onIcon`. +- **`button`** — A push button. Optional fields: `pressedLabel`, `unpressedLabel`. +- **`slider-pwm`** — A slider control. No additional fields. +- **`color-picker`** — A color picker (used for RGB LEDs). No additional fields. + +
+Example: Dimmable LED + +```json +{ + "displayName": "Dimmable LED", + "vendor": "Generic", + "pwmSetting": "fixedFrequency", + "visualization": { + "type": "slider-pwm" + } +} +``` +
+ +
+Example: RGB LED + +```json +{ + "displayName": "RGB LED", + "vendor": "Generic", + "pwmSetting": "fixedFrequency", + "visualization": { + "type": "color-picker" + } +} +``` +
+ +--- + +### Servo + +Servo components represent servo motor controllers. + +**Required fields:** `displayName`, `vendor`, `frequency`, `minPulseWidth`, `maxPulseWidth` + +| Field | Type | Description | +|-----------------|--------|-----------------------------------------------------------------------| +| `frequency` | number | PWM signal frequency in Hz (40–200, default 50). | +| `minPulseWidth` | number | Minimum pulse width in microseconds. | +| `maxPulseWidth` | number | Maximum pulse width in microseconds. | +| `visualization` | object | Only supported type: `slider-servo`. | + +
+Example: Generic Servo + +```json +{ + "displayName": "Generic Servo", + "vendor": "Generic", + "frequency": 50, + "minPulseWidth": 500, + "maxPulseWidth": 2500, + "visualization": { + "type": "slider-servo" + } +} +``` +
+ +--- + +### UART + +UART components represent serial-bus-based sensors such as air quality monitors. + +**Required fields:** `displayName`, `vendor`, `subcomponents`, `baudRate` + +| Field | Type | Description | +|-----------------|---------|---------------------------------------------------------------------------------------| +| `subcomponents` | array | List of sensor subcomponents. Each item is either a sensor type string or an object (see below). | +| `baudRate` | number | UART baud rate in bps (1200–256000). | +| `inverted` | boolean | If `true`, TX/RX signals on the UART bus are inverted. | + +#### UART Subcomponent Format + +UART subcomponents follow the same pattern as [I2C subcomponents](#i2c-subcomponent-format) — either a simple sensor type string (e.g. `"pm25-std"`) or an object — with these differences: + +| Field | Type | Description | +|----------------|--------|--------------------------------------------------------------------------------| +| `displayName` | string | **(required)** Human-friendly name of the sensor. 3–24 characters. | +| `sensorType` | string | **(required)** One of the supported [Sensor Types](#sensor-types). | +| `type` | string | A unique lookup string for this sensor and its parent component. 3–24 chars. | +| `defaultPeriod`| number | Default polling period in seconds (30–86400). Note: minimum is 30, not 1 as in I2C. | + +
+Example: PMS5003 Air Quality Sensor + +```json +{ + "displayName": "pms5003", + "vendor": "PLANTOWER", + "subcomponents": [ + "pm10-std", + "pm25-std", + "pm100-std", + "pm10-env", + "pm25-env", + "pm100-env" + ], + "baudRate": 9600, + "inverted": false +} +``` +
+ +## Sensor Types + +Sensor type strings are used in the `subcomponents` arrays of I2C, UART, and DS18x20 components. The centralized registry of display names and default polling periods is in [`components/sensors.json`](components/sensors.json). + +Every sensor type in `sensors.json` has a `defaultPeriod` of **900 seconds (15 minutes)**. This is the polling interval used unless a subcomponent's own `defaultPeriod` field overrides it (see [I2C Subcomponent Format](#i2c-subcomponent-format) and [UART Subcomponent Format](#uart-subcomponent-format)). + +| Sensor Type | Display Name | +|----------------------------|------------------------------------------| +| `unspecified` | Unspecified | +| `accelerometer` | Accelerometer | +| `magnetic-field` | Magnetic Field Sensor | +| `orientation` | Orientation Sensor | +| `gyroscope` | Gyroscope | +| `light` | Light Sensor | +| `pressure` | Pressure Sensor | +| `proximity` | Proximity Sensor | +| `gravity` | Gravity Sensor | +| `acceleration` | Acceleration Sensor | +| `rotation` | Rotation Sensor | +| `humidity` | Humidity Sensor | +| `ambient-temp` | Temperature Sensor (°C) | +| `ambient-temp-fahrenheit` | Temperature Sensor (°F) | +| `object-temp` | Thermocouple Temperature (°C) | +| `object-temp-fahrenheit` | Thermocouple Temperature (°F) | +| `voltage` | Voltage Sensor | +| `current` | Current | +| `color` | Color | +| `raw` | Raw Data | +| `pm10-std` | PM1.0 Standard | +| `pm25-std` | PM2.5 Standard | +| `pm100-std` | PM10.0 Standard | +| `pm10-env` | PM1.0 Environmental | +| `pm25-env` | PM2.5 Environmental | +| `pm100-env` | PM10.0 Environmental | +| `co2` | CO2 | +| `gas-resistance` | Total VOC | +| `altitude` | Altitude (Relative) | +| `lux` | Light Sensor (Lux) | +| `eco2` | Estimated CO2 | +| `unitless-percent` | Unitless Percentage | +| `voc-index` | Volatile Organic Compounds Index | +| `nox-index` | Nitrogen Oxides Index | +| `tvoc` | Total Volatile Organic Compounds | diff --git a/agents.md b/agents.md new file mode 100644 index 00000000..b6181032 --- /dev/null +++ b/agents.md @@ -0,0 +1 @@ +Use skills in `./.agents/skills/` diff --git a/claude.md b/claude.md new file mode 100644 index 00000000..2c8a9cfa --- /dev/null +++ b/claude.md @@ -0,0 +1 @@ +See @agents.md diff --git a/components/i2c/apds9999/definition.json b/components/i2c/apds9999/definition.json new file mode 100644 index 00000000..a2cb5462 --- /dev/null +++ b/components/i2c/apds9999/definition.json @@ -0,0 +1,9 @@ +{ + "displayName": "APDS9999", + "vendor": "Broadcom", + "productURL": "https://www.adafruit.com/product/6461", + "documentationURL": "https://learn.adafruit.com/adafruit-apds9999-proximity-lux-light-color-sensor", + "published": true, + "i2cAddresses": ["0x52"], + "subcomponents": ["light", "proximity"] +} diff --git a/components/i2c/apds9999/image.jpg b/components/i2c/apds9999/image.jpg new file mode 100644 index 00000000..2a6e6a36 Binary files /dev/null and b/components/i2c/apds9999/image.jpg differ diff --git a/components/i2c/as7331/definition.json b/components/i2c/as7331/definition.json new file mode 100644 index 00000000..1737406e --- /dev/null +++ b/components/i2c/as7331/definition.json @@ -0,0 +1,10 @@ +{ + "displayName": "AS7331", + "vendor": "ams-OSRAM", + "productURL": "https://www.adafruit.com/product/6476", + "description": "This sensor prints UVA/B/C to serial and logs UVB to Adafruit IO (WS v1 limitation)", + "documentationURL": "https://learn.adafruit.com/adafruit-as7331-uv-uva-uvb-uvc-sensor", + "published": true, + "i2cAddresses": ["0x74", "0x75", "0x76", "0x77"], + "subcomponents": [{ "displayName": "UVB", "sensorType": "raw" }] +} diff --git a/components/i2c/as7331/image.jpg b/components/i2c/as7331/image.jpg new file mode 100644 index 00000000..8a7afbe4 Binary files /dev/null and b/components/i2c/as7331/image.jpg differ diff --git a/components/i2c/ens161/definition.json b/components/i2c/ens161/definition.json new file mode 100644 index 00000000..6eb9dfb2 --- /dev/null +++ b/components/i2c/ens161/definition.json @@ -0,0 +1,16 @@ +{ + "displayName": "ENS161", + "vendor": "Sciosense", + "productURL": "https://www.adafruit.com/product/6431", + "documentationURL": "https://learn.adafruit.com/adafruit-ens161-mox-gas-sensor", + "published": true, + "i2cAddresses": [ "0x52", "0x53" ], + "subcomponents": [ + "tvoc", + "eco2", + { + "displayName": "AQI", + "sensorType": "raw" + } + ] +} diff --git a/components/i2c/ens161/image.jpg b/components/i2c/ens161/image.jpg new file mode 100644 index 00000000..3821be63 Binary files /dev/null and b/components/i2c/ens161/image.jpg differ diff --git a/components/i2c/max44009/definition.json b/components/i2c/max44009/definition.json new file mode 100644 index 00000000..953be622 --- /dev/null +++ b/components/i2c/max44009/definition.json @@ -0,0 +1,9 @@ +{ + "displayName": "MAX44009", + "vendor": "Maxim (Adafruit)", + "productURL": "https://www.adafruit.com/product/6498", + "documentationURL": "https://learn.adafruit.com/adafruit-max44009-lux-light-sensor", + "published": true, + "i2cAddresses": ["0x4A", "0x4B"], + "subcomponents": ["light"] +} diff --git a/components/i2c/max44009/image.jpg b/components/i2c/max44009/image.jpg new file mode 100644 index 00000000..9c2969f2 Binary files /dev/null and b/components/i2c/max44009/image.jpg differ diff --git a/components/i2c/sgp41/definition.json b/components/i2c/sgp41/definition.json new file mode 100644 index 00000000..7f5feae5 --- /dev/null +++ b/components/i2c/sgp41/definition.json @@ -0,0 +1,16 @@ +{ + "displayName": "SGP41", + "vendor": "Sensirion", + "productURL": "https://www.adafruit.com/product/6455", + "documentationURL": "https://learn.adafruit.com/adafruit-sgp41-multi-pixel-gas-sensor-breakout", + "published": true, + "i2cAddresses": [ "0x59" ], + "subcomponents": [ + "voc-index", + "nox-index", + { + "displayName": "Raw VOC Ticks (Reference)", + "sensorType": "raw" + } + ] +} diff --git a/components/i2c/sgp41/image.jpg b/components/i2c/sgp41/image.jpg new file mode 100644 index 00000000..41c321bf Binary files /dev/null and b/components/i2c/sgp41/image.jpg differ diff --git a/components/i2c/spa06_003/definition.json b/components/i2c/spa06_003/definition.json index 4057a870..8aad1257 100644 --- a/components/i2c/spa06_003/definition.json +++ b/components/i2c/spa06_003/definition.json @@ -4,7 +4,7 @@ "productURL": "https://www.adafruit.com/product/6420", "documentationURL": "https://learn.adafruit.com/adafruit-spa06-003-temperature-pressure-sensor/", "description": "300-1100hPa (-500 to 9000m) Relative accuracy ±3Pa (±0.25 meter), absolute ±30Pa (±2.5m). Temperature ±1°C", - "published": false, + "published": true, "i2cAddresses": [ "0x77", "0x76" ], "subcomponents": [ "ambient-temp", "ambient-temp-fahrenheit", "pressure" ] } \ No newline at end of file diff --git a/components/i2c/tmp119/definition.json b/components/i2c/tmp119/definition.json new file mode 100644 index 00000000..4dadab6a --- /dev/null +++ b/components/i2c/tmp119/definition.json @@ -0,0 +1,9 @@ +{ + "displayName": "TMP119", + "vendor": "Texas Instruments", + "productURL": "https://www.adafruit.com/product/6482", + "documentationURL": "https://learn.adafruit.com/adafruit-tmp119-high-precision-temperature-sensor", + "published": true, + "i2cAddresses": ["0x48", "0x49", "0x4A", "0x4B"], + "subcomponents": ["ambient-temp", "ambient-temp-fahrenheit"] +} diff --git a/components/i2c/tmp119/image.jpg b/components/i2c/tmp119/image.jpg new file mode 100644 index 00000000..66ac01d3 Binary files /dev/null and b/components/i2c/tmp119/image.jpg differ diff --git a/gemini.md b/gemini.md new file mode 100644 index 00000000..2c8a9cfa --- /dev/null +++ b/gemini.md @@ -0,0 +1 @@ +See @agents.md diff --git a/generate_hil_spreadsheet.py b/generate_hil_spreadsheet.py new file mode 100644 index 00000000..d09138bb --- /dev/null +++ b/generate_hil_spreadsheet.py @@ -0,0 +1,1208 @@ +#!/usr/bin/env python3 +"""Generate HIL testing spreadsheet for all I2C Wippersnapper components. + +Creates an Excel workbook with: + Sheet 1 - Component Matrix: all components, addresses, vendors, conflicts + Sheet 2 - HIL Mux Layout: conflict-free channel assignments for testing + Sheet 3 - Address Conflicts: per-address overlap summary + Sheet 4 - Test Fixtures: components grouped by measured phenomena + +Hardware config: + - 8-ch TCA9548A mux @ 0x77 (all 3 addr pads bridged) + - 4-ch TCA9544A mux @ 0x71 (A0 bridged) + - Reserved addresses on every channel: {0x77, 0x71} +""" + +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path +from collections import defaultdict + +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + + +# ── Hardware config ────────────────────────────────────────────────────────── +MUXES = [ + {"type": "TCA9548A", "address": 0x77, "channels": 8, "label": "8ch"}, + {"type": "TCA9544A", "address": 0x71, "channels": 4, "label": "4ch"}, +] +MUX_RESERVED = {m["address"] for m in MUXES} +TOTAL_MUX_CHANNELS = sum(m["channels"] for m in MUXES) # 12 + + +# ── Colours ────────────────────────────────────────────────────────────────── +HEADER_FILL = PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid") +HEADER_FONT = Font(name="Calibri", bold=True, color="FFFFFF", size=11) +CONFLICT_FILL = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") +UNIQUE_FILL = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") +UNPUBLISHED_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") +MUX_HEADER_FILL = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") +MUX2_HEADER_FILL = PatternFill(start_color="548235", end_color="548235", fill_type="solid") +CHANNEL_FILLS_MUX1 = [ + PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid"), # Ch0 + PatternFill(start_color="BDD7EE", end_color="BDD7EE", fill_type="solid"), # Ch1 + PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid"), # Ch2 + PatternFill(start_color="BDD7EE", end_color="BDD7EE", fill_type="solid"), # Ch3 + PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid"), # Ch4 + PatternFill(start_color="BDD7EE", end_color="BDD7EE", fill_type="solid"), # Ch5 + PatternFill(start_color="D9E2F3", end_color="D9E2F3", fill_type="solid"), # Ch6 + PatternFill(start_color="BDD7EE", end_color="BDD7EE", fill_type="solid"), # Ch7 +] +CHANNEL_FILLS_MUX2 = [ + PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid"), # Ch0 + PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid"), # Ch1 + PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid"), # Ch2 + PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid"), # Ch3 +] +NON_DEFAULT_FILL = PatternFill(start_color="F4B084", end_color="F4B084", fill_type="solid") # orange — assigned != default +NON_DEFAULT_FONT = Font(bold=True, color="833C0B") +NOMUX_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") +NOMUX_HEADER_FILL = PatternFill(start_color="C55A11", end_color="C55A11", fill_type="solid") +THIN_BORDER = Border( + left=Side(style="thin"), right=Side(style="thin"), + top=Side(style="thin"), bottom=Side(style="thin"), +) + + +JUMPER_JSON_PATH = "i2c_address_jumper_info.json" + + +def load_jumper_info(base_dir): + """Load cached address jumper info from JSON.""" + path = Path(base_dir) / JUMPER_JSON_PATH + if path.exists(): + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("components", {}) + return {} + + +def save_jumper_info(base_dir, jumper_db): + """Write jumper info back to the JSON cache.""" + path = Path(base_dir) / JUMPER_JSON_PATH + # Read existing file to preserve metadata + if path.exists(): + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + else: + data = {"metadata": {}, "components": {}} + data["components"] = jumper_db + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + + +def _find_claude_cli(): + """Locate the claude CLI across Windows, macOS, Linux, and WSL.""" + # Try plain 'claude' first (works on macOS/Linux, and Windows if in PATH) + cmd = shutil.which("claude") + if cmd: + return cmd + # On Windows, npm global installs create .cmd shims + if sys.platform == "win32": + for ext in (".cmd", ".exe", ".ps1"): + cmd = shutil.which("claude" + ext) + if cmd: + return cmd + # Also check common npm global locations + for candidate in [ + Path(os.environ.get("APPDATA", "")) / "npm" / "claude.cmd", + Path(os.environ.get("LOCALAPPDATA", "")) / "npm" / "claude.cmd", + ]: + if candidate.exists(): + return str(candidate) + return None + + +def fetch_jumper_info_via_claude(component_name, doc_url, addresses): + """Call 'claude -p' to research address jumper info for a component.""" + addrs_str = ", ".join(addresses) + prompt = ( + f"For the Adafruit I2C breakout board '{component_name}' " + f"(I2C addresses: {addrs_str}), " + f"documentation: {doc_url or 'none'}\n\n" + f"What is the I2C address jumper/solder pad configuration? " + f"Return ONLY a JSON object (no markdown fencing) with these fields:\n" + f' "default_address": hex string like "0x48",\n' + f' "has_address_jumper": true/false,\n' + f' "address_jumper_info": concise description of how to change address,\n' + f' "addresses": object mapping hex address to description,\n' + f' "confidence": "high" or "low"\n' + ) + + claude_cmd = _find_claude_cli() + if not claude_cmd: + print(" 'claude' CLI not found — skipping auto-fetch") + return None + + try: + # Use shell=True on Windows for .cmd shims; direct exec elsewhere + use_shell = sys.platform == "win32" and claude_cmd.endswith(".cmd") + result = subprocess.run( + [claude_cmd, "-p", prompt, "--output-format", "text"], + capture_output=True, text=True, timeout=120, + shell=use_shell, + ) + if result.returncode != 0: + print(f" claude exited with code {result.returncode}") + if result.stderr: + print(f" stderr: {result.stderr[:200]}") + return None + + # Parse JSON from output (strip any markdown fencing) + output = result.stdout.strip() + if output.startswith("```"): + output = "\n".join(output.split("\n")[1:]) + if output.endswith("```"): + output = "\n".join(output.split("\n")[:-1]) + output = output.strip() + + data = json.loads(output) + return data + except subprocess.TimeoutExpired: + print(" claude timed out (120s)") + return None + except json.JSONDecodeError as e: + print(f" failed to parse claude output as JSON: {e}") + print(f" raw output: {output[:300]}") + return None + except Exception as e: + print(f" error calling claude: {e}") + return None + + +def check_and_fetch_missing_jumper_info(base_dir, components, jumper_db): + """Check for components missing jumper info, offer to fetch via claude -p.""" + missing = [] + for comp in components: + name = comp["dir"] + if name not in jumper_db: + doc_url = comp.get("guide_url", "") + addrs = [f"0x{a:02X}" for a in comp["all_addresses"]] + missing.append((name, doc_url, addrs)) + + if not missing: + return jumper_db + + print(f"\n{'='*60}") + print(f"WARNING: {len(missing)} component(s) missing address jumper info:") + for name, doc_url, addrs in missing: + print(f" - {name} ({', '.join(addrs)})") + print(f"{'='*60}") + + if not _find_claude_cli(): + print("'claude' CLI not found — cannot auto-fetch. Add entries manually to") + print(f" {JUMPER_JSON_PATH}") + return jumper_db + + print(f"\nWill attempt to fetch info via 'claude -p' in 5 seconds...") + print("Press Ctrl+C to skip.\n") + try: + for i in range(5, 0, -1): + print(f" {i}...", end=" ", flush=True) + time.sleep(1) + print() + except KeyboardInterrupt: + print("\n Skipped.") + return jumper_db + + updated = False + for name, doc_url, addrs in missing: + print(f"\n Fetching jumper info for '{name}'...") + data = fetch_jumper_info_via_claude(name, doc_url, addrs) + if data: + jumper_db[name] = { + "default_address": data.get("default_address", addrs[0] if addrs else "?"), + "has_address_jumper": data.get("has_address_jumper", False), + "address_jumper_info": data.get("address_jumper_info", ""), + "addresses": data.get("addresses", {}), + "guide_url": doc_url or None, + "confidence": data.get("confidence", "low"), + "auto_fetched": True, + } + print(f" OK: {data.get('address_jumper_info', '')[:80]}") + updated = True + else: + print(f" FAILED — add manually to {JUMPER_JSON_PATH}") + + if updated: + save_jumper_info(base_dir, jumper_db) + print(f"\n Updated {JUMPER_JSON_PATH}") + + return jumper_db + + +def load_components(base_dir): + """Load all I2C component definitions.""" + jumper_db = load_jumper_info(base_dir) + components = [] + i2c_dir = Path(base_dir) / "components" / "i2c" + for defn in sorted(i2c_dir.glob("*/definition.json")): + with open(defn, "r", encoding="utf-8") as f: + data = json.load(f) + name = defn.parent.name + addrs = [int(a, 16) for a in data.get("i2cAddresses", [])] # preserve definition order (default first) + usable = [a for a in addrs if a not in MUX_RESERVED] + jinfo = jumper_db.get(name, {}) + components.append({ + "dir": name, + "displayName": data.get("displayName", name), + "vendor": data.get("vendorName", ""), + "all_addresses": addrs, + "usable_addresses": usable, + "published": data.get("published", True), + "sensors": [ + s["sensorType"] if isinstance(s, dict) else s + for s in data.get("subcomponents", []) + ], + "has_jumper": jinfo.get("has_address_jumper", None), + "jumper_info": jinfo.get("address_jumper_info", ""), + "jumper_addrs": jinfo.get("addresses", {}), # {hex_addr: description} + "guide_url": jinfo.get("guide_url") or data.get("documentationURL", ""), + }) + components.sort(key=lambda c: (c["all_addresses"][0] if c["all_addresses"] else 0xFF, c["dir"])) + return components + + +def build_address_map(components): + """Map address -> list of component dicts (using all_addresses).""" + addr_map = defaultdict(list) + for comp in components: + for a in comp["all_addresses"]: + addr_map[a].append(comp) + return addr_map + + +def find_conflicts(components, addr_map): + """For each component, find which other components share any address.""" + conflicts = {} + for comp in components: + peers = set() + for a in comp["all_addresses"]: + for other in addr_map[a]: + if other["dir"] != comp["dir"]: + peers.add(other["dir"]) + conflicts[comp["dir"]] = sorted(peers) + return conflicts + + +# ── Channel assignment with single-address picking ─────────────────────────── + +def assign_channels(components): + """ + Assign each component to a channel AND pick ONE specific address. + + Strategy: + 1. Put components with truly unique addresses on the direct bus (ch 0). + "Truly unique" = the component has a usable address that NO other + component lists in its usable set. These are free on the direct bus + because they block an address nobody else needs. + 2. Everything else goes onto mux channels (1-12) via greedy colouring. + Most-constrained-first (fewest usable addresses). + + Channel layout: + 0 = direct bus (no mux) — always visible + 1 .. 8 = 8ch TCA9548A @ 0x77, channels 0-7 + 9 .. 12 = 4ch TCA9544A @ 0x71, channels 0-3 + + Constraints: + - Picked address must not be in MUX_RESERVED + - No two components on the same channel share a picked address + - No muxed component's picked address clashes with any direct-bus + component's picked address (direct bus is always visible) + + Returns (assignment, picked_addr, channel_addrs). + """ + n_channels = 1 + TOTAL_MUX_CHANNELS # 13 + + channel_addrs = defaultdict(set) # ch -> set of picked addresses + direct_addrs = set() # mirror of channel_addrs[0] + assignment = {} # comp_dir -> channel + picked_addr = {} # comp_dir -> int address + + # ── Build "who else wants this address?" map ── + addr_users = defaultdict(set) # addr -> set of comp indices + for i, comp in enumerate(components): + for a in comp["usable_addresses"]: + addr_users[a].add(i) + + # ── Phase 1: direct bus — truly-unique addresses ── + # An address is "unique" if exactly one component lists it as usable. + placed_indices = set() + for i, comp in enumerate(components): + usable = comp["usable_addresses"] + if not usable: + continue + # Find addresses where this is the ONLY user + unique_addrs = [a for a in usable if len(addr_users[a]) == 1] + if unique_addrs: + # Pick the first unique address + addr = unique_addrs[0] + channel_addrs[0].add(addr) + direct_addrs.add(addr) + assignment[comp["dir"]] = 0 + picked_addr[comp["dir"]] = addr + placed_indices.add(i) + + # ── Phase 2: mux channels for everything else ── + remaining = [i for i in range(len(components)) if i not in placed_indices] + # Sort: fewest usable addresses first (most constrained) + remaining.sort(key=lambda i: (len(components[i]["usable_addresses"]), + components[i]["dir"])) + + for i in remaining: + comp = components[i] + usable = comp["usable_addresses"] + placed = False + + if not usable: + assignment[comp["dir"]] = -1 + picked_addr[comp["dir"]] = None + continue + + # Try mux channels 1..12, then direct bus (0) as last resort + channel_order = list(range(1, n_channels)) + [0] + for ch in channel_order: + for addr in usable: + # Already taken on this channel? + if addr in channel_addrs[ch]: + continue + # Mux channel: can't clash with direct bus + if ch > 0 and addr in direct_addrs: + continue + # Direct bus: can't clash with any mux channel + if ch == 0: + if any(addr in channel_addrs[mch] + for mch in range(1, n_channels)): + continue + + channel_addrs[ch].add(addr) + if ch == 0: + direct_addrs.add(addr) + assignment[comp["dir"]] = ch + picked_addr[comp["dir"]] = addr + placed = True + break + if placed: + break + + if not placed: + assignment[comp["dir"]] = -1 + picked_addr[comp["dir"]] = None + + return assignment, picked_addr, channel_addrs + + +def channel_label(ch): + """Human-readable label for a channel number.""" + if ch == 0: + return "Direct Bus (No Mux)" + elif ch <= 8: + m = MUXES[0] + return f"{m['type']} (0x{m['address']:02X}) Ch{ch - 1}" + else: + m = MUXES[1] + return f"{m['type']} (0x{m['address']:02X}) Ch{ch - 9}" + + +def channel_short_label(ch): + """Short label for JSON export.""" + if ch == 0: + return "direct" + elif ch <= 8: + return f"8ch_mux_ch{ch - 1}" + else: + return f"4ch_mux_ch{ch - 9}" + + +def channel_fill(ch): + """Background colour for a channel.""" + if ch == 0: + return NOMUX_FILL + elif ch <= 8: + return CHANNEL_FILLS_MUX1[(ch - 1) % len(CHANNEL_FILLS_MUX1)] + else: + return CHANNEL_FILLS_MUX2[(ch - 9) % len(CHANNEL_FILLS_MUX2)] + + +def channel_header_fill(ch): + """Header colour for a channel.""" + if ch == 0: + return NOMUX_HEADER_FILL + elif ch <= 8: + return MUX_HEADER_FILL + else: + return MUX2_HEADER_FILL + + +# ── Spreadsheet generation ─────────────────────────────────────────────────── + +def write_sheet1(ws, components, addr_map, conflicts): + """Component Matrix + Addresses sheet.""" + ws.title = "Component Matrix" + + all_addrs = sorted(set(a for c in components for a in c["all_addresses"])) + + headers = [ + "Component", "Display Name", "Vendor", "Published", + "I2C Addresses", "# Addrs", "Usable (excl mux)", + "Has Jumper", "Jumper Info", "Guide URL", + "Conflicts With", "# Conflicts", "Sensor Types", + ] + for a in all_addrs: + h = f"0x{a:02X}" + if a in MUX_RESERVED: + h += " MUX" + headers.append(h) + + for col, h in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=h) + cell.font = HEADER_FONT + cell.fill = HEADER_FILL + cell.alignment = Alignment(horizontal="center", wrap_text=True) + cell.border = THIN_BORDER + + for row_idx, comp in enumerate(components, 2): + conflict_list = conflicts.get(comp["dir"], []) + is_conflicted = len(conflict_list) > 0 + + has_j = comp["has_jumper"] + jumper_str = "Yes" if has_j else ("No" if has_j is False else "?") + vals = [ + comp["dir"], + comp["displayName"], + comp["vendor"], + "Yes" if comp["published"] else "NO", + ", ".join(f"0x{a:02X}" for a in comp["all_addresses"]), + len(comp["all_addresses"]), + ", ".join(f"0x{a:02X}" for a in comp["usable_addresses"]), + jumper_str, + comp["jumper_info"], + comp["guide_url"], + ", ".join(conflict_list) if conflict_list else "None", + len(conflict_list), + ", ".join(comp["sensors"]) if comp["sensors"] else "", + ] + + for col, v in enumerate(vals, 1): + cell = ws.cell(row=row_idx, column=col, value=v) + cell.border = THIN_BORDER + cell.alignment = Alignment(wrap_text=True, vertical="top") + if not comp["published"]: + cell.fill = UNPUBLISHED_FILL + elif is_conflicted and col in (5, 11): + cell.fill = CONFLICT_FILL + elif not is_conflicted and col == 5: + cell.fill = UNIQUE_FILL + + # Address heatmap columns + addr_col_start = len(vals) + 1 + for ai, a in enumerate(all_addrs): + col = addr_col_start + ai + cell = ws.cell(row=row_idx, column=col) + cell.border = THIN_BORDER + cell.alignment = Alignment(horizontal="center") + if a in comp["all_addresses"]: + n_sharing = len(addr_map[a]) + cell.value = n_sharing + if a in MUX_RESERVED: + cell.fill = PatternFill(start_color="BF8F00", end_color="BF8F00", fill_type="solid") + cell.font = Font(bold=True, color="FFFFFF") + elif n_sharing > 1: + cell.fill = CONFLICT_FILL + cell.font = Font(bold=True, color="9C0006") + else: + cell.fill = UNIQUE_FILL + cell.font = Font(color="006100") + + col_widths = {1: 18, 2: 28, 3: 26, 4: 10, 5: 40, 6: 8, 7: 36, + 8: 10, 9: 50, 10: 40, 11: 50, 12: 10, 13: 30} + for c, w in col_widths.items(): + ws.column_dimensions[get_column_letter(c)].width = w + for ai in range(len(all_addrs)): + ws.column_dimensions[get_column_letter(len(col_widths) + 1 + ai)].width = 6.5 + + ws.freeze_panes = "A2" + ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}{len(components) + 1}" + + +def _jumper_setting(comp, addr): + """Return (short_setting, full_info) for reaching a specific address. + + short_setting: e.g. "A0:1 A1:0", "ADDR:closed", "SDO:GND" + full_info: the full jumper_info text + """ + import re + + if addr is None: + return ("", "") + + addr_hex = f"0x{addr:02X}" + addr_hex_upper = addr_hex.upper() + jumper_addrs = comp.get("jumper_addrs", {}) + full_info = comp.get("jumper_info", "") + has_jumper = comp.get("has_jumper") + + # No jumper info at all + if not full_info: + return ("mux isolation", "No jumper info available") + + # has_jumper=False but might still have pin-selectable addresses (e.g. SHT3x ADR pin) + # So still try the address lookup below before giving up + + # Look up the per-address description from the JSON cache + desc = "" + for k, v in jumper_addrs.items(): + if k.upper() == addr_hex_upper: + desc = v + break + # Also try if addr falls within a range key like "0x28-0x2F" + if not desc: + for k, v in jumper_addrs.items(): + if "-" in k: + try: + lo, hi = k.split("-") + if int(lo, 16) <= addr <= int(hi, 16): + desc = v + break + except ValueError: + pass + + if not desc: + if has_jumper is False: + return ("mux isolation", full_info or "Fixed address — no jumper") + # Address not in cache — truncate full info + return (full_info[:50] + "...", full_info) + + # ── Convert description to concise pad notation ── + d = desc.lower() + + if "default" in d: + return ("default", full_info) + + # SDO/ADDR single-jumper patterns + if "sdo" in d: + if "gnd" in d: + return ("SDO:GND", full_info) + elif "bridged" in d or "closed" in d: + return ("SDO:GND", full_info) + elif "high" in d or "vdd" in d: + return ("SDO:VDD", full_info) + return ("SDO:closed", full_info) + + if "addr" in d and ("closed" in d or "bridged" in d): + return ("ADDR:closed", full_info) + if "addr" in d and ("vdd" in d or "vin" in d or "high" in d): + return ("ADDR:VDD", full_info) + + if "sa0" in d: + return ("SA0:closed" if "closed" in d else "SA0:open", full_info) + + if "adr" in d: + if "vin" in d or "vdd" in d or "high" in d: + return ("ADR:VDD", full_info) + return ("ADR:GND", full_info) + + # Multi-pad patterns: A0, A1, A2, AD0, AD1 + pads = re.findall(r'\b(A[D]?\d)\b', desc, re.IGNORECASE) + if pads: + states = [] + for p in pads: + states.append(f"{p.upper()}:1") + return (" ".join(states), full_info) + + # Named jumpers like "43k jumper closed" + jumper_match = re.search(r'(\w+)\s+jumper\s+(closed|open|bridged)', desc, re.IGNORECASE) + if jumper_match: + return (f"{jumper_match.group(1)}:{jumper_match.group(2)}", full_info) + + # Fallback: use the description as-is but trimmed + return (desc[:40], full_info) + + +def write_sheet2(ws, components, assignment, picked_addr, channel_addrs): + """HIL Mux Layout sheet.""" + ws.title = "HIL Mux Layout" + + n_channels = 1 + TOTAL_MUX_CHANNELS + + # Organise by channel + channels = defaultdict(list) + unplaceable = [] + for comp in components: + ch = assignment.get(comp["dir"], -1) + if ch < 0: + unplaceable.append(comp) + else: + channels[ch].append(comp) + + for ch in channels: + channels[ch].sort(key=lambda c: (picked_addr.get(c["dir"], 0) or 0, c["dir"])) + + # ── Summary section ── + row = 1 + ws.cell(row=row, column=1, value="HIL I2C Mux Testing Layout").font = Font( + name="Calibri", bold=True, size=14 + ) + row += 1 + ws.cell(row=row, column=1, value=f"Total components: {len(components)}") + row += 1 + placed = sum(len(v) for v in channels.values()) + ws.cell(row=row, column=1, value=f"Placed: {placed} | Unplaceable: {len(unplaceable)}") + row += 1 + + for m in MUXES: + ws.cell(row=row, column=1, + value=f"{m['label']}: {m['type']} @ 0x{m['address']:02X} — {m['channels']} channels") + row += 1 + + ws.cell(row=row, column=1, + value=f"Reserved addresses (mux chips): {', '.join(f'0x{a:02X}' for a in sorted(MUX_RESERVED))}") + row += 1 + + # Channel utilisation summary + row += 1 + ws.cell(row=row, column=1, value="Channel Summary").font = Font(bold=True, size=11) + row += 1 + sum_headers = ["Channel", "Label", "Components", "Addresses Used"] + for hi, h in enumerate(sum_headers): + cell = ws.cell(row=row, column=hi + 1, value=h) + cell.font = Font(bold=True) + cell.fill = PatternFill(start_color="D9D9D9", end_color="D9D9D9", fill_type="solid") + cell.border = THIN_BORDER + row += 1 + for ch in range(n_channels): + comps_in = channels.get(ch, []) + addrs_in = channel_addrs.get(ch, set()) + vals = [ch, channel_label(ch), len(comps_in), + ", ".join(f"0x{a:02X}" for a in sorted(addrs_in))] + for hi, v in enumerate(vals): + cell = ws.cell(row=row, column=hi + 1, value=v) + cell.border = THIN_BORDER + cell.fill = channel_fill(ch) + row += 1 + + # Unplaceable warnings + if unplaceable: + row += 1 + cell = ws.cell(row=row, column=1, + value="UNPLACEABLE — all addresses blocked by mux reserved addresses:") + cell.font = Font(bold=True, color="CC0000") + row += 1 + for comp in unplaceable: + ws.cell(row=row, column=1, + value=f" {comp['dir']} ({comp['displayName']}) — " + f"addresses: {', '.join(f'0x{a:02X}' for a in comp['all_addresses'])}") + row += 1 + + row += 2 + + # ── Channel blocks side-by-side ── + BLOCK_WIDTH = 7 + block_headers = ["#", "Component", "Display Name", "Assigned Addr", "Jumper Setting", "All Addrs", "Vendor"] + layout_start_row = row + + active_channels = sorted(ch for ch in range(n_channels) if channels.get(ch)) + + for block_idx, ch in enumerate(active_channels): + col_offset = block_idx * BLOCK_WIDTH + comps_in = channels.get(ch, []) + + # Header + r = layout_start_row + lbl = channel_label(ch) + for hi in range(BLOCK_WIDTH): + c = col_offset + hi + 1 + cell = ws.cell(row=r, column=c, value=lbl if hi == 0 else "") + cell.font = Font(bold=True, color="FFFFFF", size=11) + cell.fill = channel_header_fill(ch) + cell.border = THIN_BORDER + cell.alignment = Alignment(horizontal="center") + ws.merge_cells(start_row=r, start_column=col_offset + 1, + end_row=r, end_column=col_offset + BLOCK_WIDTH) + + # Info + r += 1 + info = f"{len(comps_in)} components" + for hi in range(BLOCK_WIDTH): + c = col_offset + hi + 1 + cell = ws.cell(row=r, column=c, value=info if hi == 0 else "") + cell.font = Font(italic=True, size=9) + cell.fill = channel_fill(ch) + cell.border = THIN_BORDER + ws.merge_cells(start_row=r, start_column=col_offset + 1, + end_row=r, end_column=col_offset + BLOCK_WIDTH) + + # Column headers + r += 1 + for hi, hdr in enumerate(block_headers): + c = col_offset + hi + 1 + cell = ws.cell(row=r, column=c, value=hdr) + cell.font = Font(bold=True, size=10) + cell.fill = channel_fill(ch) + cell.border = THIN_BORDER + cell.alignment = Alignment(horizontal="center") + + # Component rows + for ci, comp in enumerate(comps_in): + r += 1 + pa = picked_addr.get(comp["dir"]) + default_addr = comp["all_addresses"][0] if comp["all_addresses"] else None + is_non_default = pa is not None and pa != default_addr + short_setting, _ = _jumper_setting(comp, pa) if is_non_default else ("", "") + vals = [ + ci + 1, + comp["dir"], + comp["displayName"], + f"0x{pa:02X}" if pa is not None else "?", + short_setting, + ", ".join(f"0x{a:02X}" for a in comp["all_addresses"]), + comp["vendor"], + ] + for hi, v in enumerate(vals): + c = col_offset + hi + 1 + cell = ws.cell(row=r, column=c, value=v) + cell.border = THIN_BORDER + cell.alignment = Alignment(wrap_text=True, vertical="top") + if not comp["published"]: + cell.fill = UNPUBLISHED_FILL + elif is_non_default and hi == 3: # Assigned Addr column + cell.fill = NON_DEFAULT_FILL + cell.font = NON_DEFAULT_FONT + elif is_non_default and hi == 4: # Jumper Setting column + cell.fill = NON_DEFAULT_FILL + cell.font = NON_DEFAULT_FONT + + # Column widths + ws.column_dimensions[get_column_letter(col_offset + 1)].width = 4 + ws.column_dimensions[get_column_letter(col_offset + 2)].width = 18 + ws.column_dimensions[get_column_letter(col_offset + 3)].width = 24 + ws.column_dimensions[get_column_letter(col_offset + 4)].width = 12 + ws.column_dimensions[get_column_letter(col_offset + 5)].width = 30 + ws.column_dimensions[get_column_letter(col_offset + 6)].width = 30 + ws.column_dimensions[get_column_letter(col_offset + 7)].width = 22 + + # ── Linear ordered list for JSON export ── + row2 = layout_start_row + linear_col = len(active_channels) * BLOCK_WIDTH + 2 + + ws.cell(row=row2, column=linear_col, + value="Ordered Layout (for JSON / pytest export)").font = Font(bold=True, size=12) + row2 += 1 + + linear_headers = ["Order", "Channel#", "Channel Label", "Component", "Display Name", + "Assigned Address", "Default Address", "Jumper Setting", + "All Addresses", "Has Jumper", "Jumper Details", "Guide URL", + "Vendor", "Published", "Non-Default?"] + for hi, hdr in enumerate(linear_headers): + cell = ws.cell(row=row2, column=linear_col + hi, value=hdr) + cell.font = HEADER_FONT + cell.fill = HEADER_FILL + cell.border = THIN_BORDER + cell.alignment = Alignment(horizontal="center", wrap_text=True) + row2 += 1 + + order_num = 1 + for ch in range(n_channels): + for comp in channels.get(ch, []): + pa = picked_addr.get(comp["dir"]) + default_addr = comp["all_addresses"][0] if comp["all_addresses"] else None + is_non_default = pa is not None and pa != default_addr + has_j = comp["has_jumper"] + jumper_str = "Yes" if has_j else ("No" if has_j is False else "?") + short_setting, full_info = _jumper_setting(comp, pa) if is_non_default else ("", "") + vals = [ + order_num, ch, channel_short_label(ch), + comp["dir"], comp["displayName"], + f"0x{pa:02X}" if pa is not None else "?", + f"0x{default_addr:02X}" if default_addr is not None else "?", + short_setting, + ", ".join(f"0x{a:02X}" for a in comp["all_addresses"]), + jumper_str, + full_info, + comp["guide_url"], + comp["vendor"], + "yes" if comp["published"] else "no", + "NON-DEFAULT" if is_non_default else "", + ] + for hi, v in enumerate(vals): + cell = ws.cell(row=row2, column=linear_col + hi, value=v) + cell.border = THIN_BORDER + cell.alignment = Alignment(wrap_text=True, vertical="top") + cell.fill = channel_fill(ch) + # Jumper Setting (7) and Non-Default (14) + if is_non_default and hi in (5, 7, 14): + cell.fill = NON_DEFAULT_FILL + cell.font = NON_DEFAULT_FONT + order_num += 1 + row2 += 1 + + widths = [6, 9, 16, 18, 28, 14, 14, 16, 36, 10, 50, 40, 26, 9, 14] + for wi, w in enumerate(widths): + ws.column_dimensions[get_column_letter(linear_col + wi)].width = w + + ws.freeze_panes = "A1" + + +def write_sheet3(ws, components, addr_map): + """Address conflict summary sheet.""" + ws.title = "Address Conflicts" + + all_addrs = sorted(set(a for c in components for a in c["all_addresses"])) + + headers = ["Address", "Mux?", "# Components", "Components"] + for col, h in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=h) + cell.font = HEADER_FONT + cell.fill = HEADER_FILL + cell.border = THIN_BORDER + + row = 2 + for a in all_addrs: + comps_at = addr_map[a] + n = len(comps_at) + is_mux = a in MUX_RESERVED + ws.cell(row=row, column=1, value=f"0x{a:02X}").border = THIN_BORDER + mux_cell = ws.cell(row=row, column=2, value="MUX" if is_mux else "") + mux_cell.border = THIN_BORDER + cell_n = ws.cell(row=row, column=3, value=n) + cell_n.border = THIN_BORDER + cell_comps = ws.cell( + row=row, column=4, + value=", ".join(f"{c['dir']} ({c['displayName']})" for c in comps_at), + ) + cell_comps.border = THIN_BORDER + cell_comps.alignment = Alignment(wrap_text=True, vertical="top") + + if is_mux: + for c in range(1, 5): + ws.cell(row=row, column=c).fill = PatternFill( + start_color="BF8F00", end_color="BF8F00", fill_type="solid") + mux_cell.font = Font(bold=True, color="FFFFFF") + elif n > 1: + ws.cell(row=row, column=1).fill = CONFLICT_FILL + cell_n.fill = CONFLICT_FILL + cell_n.font = Font(bold=True, color="9C0006") + else: + ws.cell(row=row, column=1).fill = UNIQUE_FILL + row += 1 + + ws.column_dimensions["A"].width = 10 + ws.column_dimensions["B"].width = 8 + ws.column_dimensions["C"].width = 14 + ws.column_dimensions["D"].width = 120 + ws.freeze_panes = "A2" + ws.auto_filter.ref = f"A1:D{row - 1}" + + +# ── Phenomena mapping ────────────────────────────────────────────────────── +# Map sensor types to physical phenomena and suggested test fixtures. +SENSOR_TO_PHENOMENON = { + "ambient-temp": "Temperature", + "ambient-temp-fahrenheit": "Temperature", + "object-temp": "Temperature (IR)", + "object-temp-fahrenheit": "Temperature (IR)", + "humidity": "Humidity", + "pressure": "Pressure", + "altitude": "Pressure", # derived from pressure + "light": "Light", + "proximity": "Proximity / ToF", + "gas-resistance": "Gas / Air Quality", + "raw": "Gas / Air Quality", + "tvoc": "Gas / Air Quality", + "eco2": "Gas / Air Quality", + "co2": "Gas / Air Quality", + "voc-index": "Gas / Air Quality", + "nox-index": "Gas / Air Quality", + "pm10-std": "Particulate Matter", + "pm25-std": "Particulate Matter", + "pm100-std": "Particulate Matter", + "current": "Current / Voltage", + "voltage": "Current / Voltage", + "unitless-percent": "Other (Percent)", +} + +PHENOMENON_FIXTURES = { + "Temperature": "Heat source / Peltier module for warming and cooling; thermally isolated enclosure", + "Temperature (IR)": "IR heat lamp or warm object at known distance; background target for baseline", + "Humidity": "Sealed enclosure with wet sponge / desiccant; or ultrasonic humidifier", + "Pressure": "Sealed chamber with hand pump or syringe for pressure changes", + "Light": "Dimmable LED or lamp with known lux levels; light-tight enclosure for dark baseline", + "Proximity / ToF": "Servo-driven target at known distances; flat reflective surface", + "Gas / Air Quality": "Sealed chamber with known VOC source (e.g. isopropyl alcohol swab); clean air baseline", + "Particulate Matter": "Sealed chamber with smoke/dust source; HEPA-filtered clean air baseline", + "Current / Voltage": "Programmable power supply or known resistive load; DAC for voltage reference", + "Other (Percent)": "Depends on specific sensor (e.g. soil moisture probe in wet/dry soil)", +} + +PHENOMENON_FILLS = { + "Temperature": PatternFill(start_color="FFCCCC", end_color="FFCCCC", fill_type="solid"), + "Temperature (IR)": PatternFill(start_color="FFD9CC", end_color="FFD9CC", fill_type="solid"), + "Humidity": PatternFill(start_color="CCE5FF", end_color="CCE5FF", fill_type="solid"), + "Pressure": PatternFill(start_color="E5CCFF", end_color="E5CCFF", fill_type="solid"), + "Light": PatternFill(start_color="FFFFCC", end_color="FFFFCC", fill_type="solid"), + "Proximity / ToF": PatternFill(start_color="CCF2FF", end_color="CCF2FF", fill_type="solid"), + "Gas / Air Quality": PatternFill(start_color="D9FFD9", end_color="D9FFD9", fill_type="solid"), + "Particulate Matter": PatternFill(start_color="E6E6E6", end_color="E6E6E6", fill_type="solid"), + "Current / Voltage": PatternFill(start_color="FFE5CC", end_color="FFE5CC", fill_type="solid"), + "Other (Percent)": PatternFill(start_color="F0F0F0", end_color="F0F0F0", fill_type="solid"), +} + + +def _classify_component(comp): + """Return set of phenomena a component measures.""" + phenomena = set() + for s in comp.get("sensors", []): + p = SENSOR_TO_PHENOMENON.get(s) + if p: + phenomena.add(p) + return phenomena + + +def write_sheet4(ws, components, assignment, picked_addr): + """Test Fixtures — components grouped by measured phenomena.""" + ws.title = "Test Fixtures" + + # Build phenomenon -> list of components + phenom_comps = defaultdict(list) + for comp in components: + for p in _classify_component(comp): + phenom_comps[p].append(comp) + + # Sort phenomena by component count descending + sorted_phenomena = sorted(phenom_comps.keys(), + key=lambda p: (-len(phenom_comps[p]), p)) + + # ── Summary table ── + row = 1 + ws.cell(row=row, column=1, + value="Test Fixtures by Measured Phenomenon").font = Font( + name="Calibri", bold=True, size=14) + row += 2 + + summary_headers = ["Phenomenon", "# Components", "Suggested Test Fixture"] + for hi, h in enumerate(summary_headers): + cell = ws.cell(row=row, column=hi + 1, value=h) + cell.font = HEADER_FONT + cell.fill = HEADER_FILL + cell.border = THIN_BORDER + cell.alignment = Alignment(horizontal="center", wrap_text=True) + row += 1 + + for p in sorted_phenomena: + fill = PHENOMENON_FILLS.get(p, PatternFill()) + vals = [p, len(phenom_comps[p]), PHENOMENON_FIXTURES.get(p, "")] + for hi, v in enumerate(vals): + cell = ws.cell(row=row, column=hi + 1, value=v) + cell.border = THIN_BORDER + cell.fill = fill + cell.alignment = Alignment(wrap_text=True, vertical="top") + row += 1 + + # Identify multi-phenomenon components + multi = [(comp, _classify_component(comp)) + for comp in components + if len(_classify_component(comp)) > 1] + + if multi: + row += 1 + ws.cell(row=row, column=1, + value="Multi-Phenomenon Sensors (need multiple fixtures)").font = Font( + bold=True, size=12) + row += 1 + multi_headers = ["Component", "Display Name", "Phenomena", "Channel", "Address"] + for hi, h in enumerate(multi_headers): + cell = ws.cell(row=row, column=hi + 1, value=h) + cell.font = HEADER_FONT + cell.fill = HEADER_FILL + cell.border = THIN_BORDER + row += 1 + for comp, phens in sorted(multi, key=lambda x: (-len(x[1]), x[0]["dir"])): + ch = assignment.get(comp["dir"], -1) + pa = picked_addr.get(comp["dir"]) + vals = [ + comp["dir"], + comp["displayName"], + ", ".join(sorted(phens)), + channel_label(ch) if ch >= 0 else "unplaced", + f"0x{pa:02X}" if pa is not None else "?", + ] + for hi, v in enumerate(vals): + cell = ws.cell(row=row, column=hi + 1, value=v) + cell.border = THIN_BORDER + cell.alignment = Alignment(wrap_text=True, vertical="top") + row += 1 + + row += 2 + + # ── Per-phenomenon detail blocks ── + ws.cell(row=row, column=1, + value="Components by Phenomenon").font = Font(bold=True, size=14) + row += 1 + + detail_headers = ["#", "Component", "Display Name", "Sensor Types", + "Channel", "Address", "Jumper Setting"] + + for p in sorted_phenomena: + row += 1 + fill = PHENOMENON_FILLS.get(p, PatternFill()) + header_fill = PatternFill( + start_color=fill.start_color.rgb[2:] if fill.start_color and fill.start_color.rgb else "333333", + end_color=fill.start_color.rgb[2:] if fill.start_color and fill.start_color.rgb else "333333", + fill_type="solid") + + # Phenomenon header + fixture_text = PHENOMENON_FIXTURES.get(p, "") + cell = ws.cell(row=row, column=1, + value=f"{p} ({len(phenom_comps[p])} components)") + cell.font = Font(bold=True, size=12) + cell.fill = fill + for ci in range(len(detail_headers)): + ws.cell(row=row, column=ci + 1).fill = fill + row += 1 + + # Fixture note + cell = ws.cell(row=row, column=1, value=f"Fixture: {fixture_text}") + cell.font = Font(italic=True, size=10, color="555555") + row += 1 + + # Column headers + for hi, h in enumerate(detail_headers): + cell = ws.cell(row=row, column=hi + 1, value=h) + cell.font = Font(bold=True, size=10) + cell.fill = fill + cell.border = THIN_BORDER + cell.alignment = Alignment(horizontal="center") + row += 1 + + # Sort by channel then name + comps_sorted = sorted(phenom_comps[p], + key=lambda c: (assignment.get(c["dir"], 99), c["dir"])) + for ci, comp in enumerate(comps_sorted): + ch = assignment.get(comp["dir"], -1) + pa = picked_addr.get(comp["dir"]) + default_addr = comp["all_addresses"][0] if comp["all_addresses"] else None + is_non_default = pa is not None and pa != default_addr + short_setting, _ = _jumper_setting(comp, pa) if is_non_default else ("", "") + vals = [ + ci + 1, + comp["dir"], + comp["displayName"], + ", ".join(comp["sensors"]), + channel_label(ch) if ch >= 0 else "unplaced", + f"0x{pa:02X}" if pa is not None else "?", + short_setting, + ] + for hi, v in enumerate(vals): + cell = ws.cell(row=row, column=hi + 1, value=v) + cell.border = THIN_BORDER + cell.alignment = Alignment(wrap_text=True, vertical="top") + if is_non_default and hi in (5, 6): + cell.fill = NON_DEFAULT_FILL + cell.font = NON_DEFAULT_FONT + row += 1 + + # Column widths + ws.column_dimensions["A"].width = 26 + ws.column_dimensions["B"].width = 20 + ws.column_dimensions["C"].width = 28 + ws.column_dimensions["D"].width = 35 + ws.column_dimensions["E"].width = 30 + ws.column_dimensions["F"].width = 12 + ws.column_dimensions["G"].width = 20 + ws.freeze_panes = "A1" + + +def main(): + base_dir = Path(__file__).parent + components = load_components(base_dir) + + # Check for missing jumper info and auto-fetch if possible + jumper_db = load_jumper_info(base_dir) + jumper_db = check_and_fetch_missing_jumper_info(base_dir, components, jumper_db) + # Reload components to pick up any newly fetched info + if any(c["dir"] not in jumper_db or c.get("has_jumper") is None for c in components): + components = load_components(base_dir) + + addr_map = build_address_map(components) + conflicts = find_conflicts(components, addr_map) + + print(f"Loaded {len(components)} I2C components") + print(f"Unique I2C addresses: {len(set(a for c in components for a in c['all_addresses']))}") + print(f"Mux reserved addresses: {', '.join(f'0x{a:02X}' for a in sorted(MUX_RESERVED))}") + print() + + for m in MUXES: + print(f" {m['label']}: {m['type']} @ 0x{m['address']:02X} ({m['channels']} channels)") + print(f" Total mux channels: {TOTAL_MUX_CHANNELS}") + print() + + assignment, picked_addr, channel_addrs = assign_channels(components) + + n_channels = 1 + TOTAL_MUX_CHANNELS + placed = sum(1 for v in assignment.values() if v >= 0) + unplaced = sum(1 for v in assignment.values() if v < 0) + print(f"Placed: {placed} | Unplaceable: {unplaced}") + + for ch in range(n_channels): + comps_in = [c for c in components if assignment.get(c["dir"]) == ch] + if not comps_in: + continue + addrs_in = channel_addrs.get(ch, set()) + print(f" {channel_label(ch)}: {len(comps_in)} components, " + f"addrs: {', '.join(f'0x{a:02X}' for a in sorted(addrs_in))}") + + unplaced_comps = [c for c in components if assignment.get(c["dir"], -1) < 0] + if unplaced_comps: + print("\nUNPLACEABLE:") + for c in unplaced_comps: + print(f" {c['dir']} — all addresses blocked by mux: " + f"{', '.join(f'0x{a:02X}' for a in c['all_addresses'])}") + + # Verify no conflicts + errors = 0 + direct_addrs = channel_addrs.get(0, set()) + for ch in range(n_channels): + seen = {} + comps_in = [c for c in components if assignment.get(c["dir"]) == ch] + for comp in comps_in: + pa = picked_addr[comp["dir"]] + if pa in seen: + print(f"BUG: channel {ch} addr 0x{pa:02X} used by {seen[pa]} AND {comp['dir']}") + errors += 1 + seen[pa] = comp["dir"] + if ch > 0 and pa in direct_addrs: + print(f"BUG: mux ch{ch} {comp['dir']} @ 0x{pa:02X} conflicts with direct bus") + errors += 1 + if errors == 0: + print("\nVERIFIED: zero address conflicts across all channels") + else: + print(f"\n{errors} BUGS FOUND") + + # Create workbook + wb = Workbook() + ws1 = wb.active + write_sheet1(ws1, components, addr_map, conflicts) + + ws2 = wb.create_sheet() + write_sheet2(ws2, components, assignment, picked_addr, channel_addrs) + + ws3 = wb.create_sheet() + write_sheet3(ws3, components, addr_map) + + ws4 = wb.create_sheet() + write_sheet4(ws4, components, assignment, picked_addr) + + out_path = base_dir / "hil_i2c_components.xlsx" + wb.save(out_path) + print(f"\nSpreadsheet saved to: {out_path}") + + +if __name__ == "__main__": + main() diff --git a/hil_i2c_components.xlsx b/hil_i2c_components.xlsx new file mode 100644 index 00000000..34bc769e Binary files /dev/null and b/hil_i2c_components.xlsx differ diff --git a/i2c_address_jumper_info.json b/i2c_address_jumper_info.json new file mode 100644 index 00000000..2dd729c7 --- /dev/null +++ b/i2c_address_jumper_info.json @@ -0,0 +1,750 @@ +{ + "metadata": { + "description": "I2C address jumper/solder pad configuration for Wippersnapper components", + "generated": "2026-04-15", + "source": "Adafruit Learn Guides (?view=all text) + datasheets", + "mux_reserved_addresses": ["0x71", "0x77"], + "mux_hardware": [ + {"type": "TCA9548A", "address": "0x77", "channels": 8}, + {"type": "TCA9544A", "address": "0x71", "channels": 4} + ] + }, + "components": { + "adt7410": { + "default_address": "0x48", + "has_address_jumper": true, + "address_jumper_info": "Two solder jumpers on back: A0 and A1. Address = 0x48 + A0(1) + A1(2). Both open: 0x48 (default). A0 closed: 0x49. A1 closed: 0x4A. Both closed: 0x4B.", + "addresses": {"0x48": "default (A0 open, A1 open)", "0x49": "A0 closed", "0x4A": "A1 closed", "0x4B": "A0+A1 closed"}, + "guide_url": "https://learn.adafruit.com/adt7410-breakout", + "confidence": "high" + }, + "aht20": { + "default_address": "0x38", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x38, cannot be changed. Guide states: 'There is only one I2C address so it's not a good option when you need multiple humidity sensors.'", + "addresses": {"0x38": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-aht20", + "confidence": "high" + }, + "aht21": { + "default_address": "0x38", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x38, cannot be changed. Same ASAIR chip family as AHT20.", + "addresses": {"0x38": "fixed"}, + "guide_url": null, + "confidence": "high" + }, + "am2301b": { + "default_address": "0x38", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x38, cannot be changed. ASAIR sensor.", + "addresses": {"0x38": "fixed"}, + "guide_url": null, + "confidence": "high" + }, + "am2315c": { + "default_address": "0x38", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x38, cannot be changed. ASAIR sensor.", + "addresses": {"0x38": "fixed"}, + "guide_url": null, + "confidence": "high" + }, + "apds9999": { + "default_address": "0x52", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x52, no jumper pads.", + "addresses": {"0x52": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-apds9999-proximity-lux-light-color-sensor", + "confidence": "high" + }, + "as5600": { + "default_address": "0x36", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x36, no configuration options on Adafruit breakout.", + "addresses": {"0x36": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-as5600-magnetic-angle-sensor", + "confidence": "high" + }, + "as7331": { + "default_address": "0x74", + "has_address_jumper": true, + "address_jumper_info": "Two jumper pads on back: A0 and A1. Address = 0x74 + A0(1) + A1(2). Both open: 0x74 (default). A0 closed: 0x75. A1 closed: 0x76. Both closed: 0x77.", + "addresses": {"0x74": "default (A0 open, A1 open)", "0x75": "A0 closed", "0x76": "A1 closed", "0x77": "A0+A1 closed"}, + "guide_url": "https://learn.adafruit.com/adafruit-as7331-uv-uva-uvb-uvc-sensor", + "confidence": "high" + }, + "bh1750": { + "default_address": "0x23", + "has_address_jumper": true, + "address_jumper_info": "ADDR solder jumper on back. Default (open, pin low) = 0x23. Bridge jumper (pin high) = 0x5C.", + "addresses": {"0x23": "default (ADDR open)", "0x5C": "ADDR jumper bridged"}, + "guide_url": "https://learn.adafruit.com/adafruit-bh1750-ambient-light-sensor", + "confidence": "high" + }, + "bme280": { + "default_address": "0x77", + "has_address_jumper": true, + "address_jumper_info": "Solder ADDR jumper on back (or wire SDO to GND). Default (open/SDO high) = 0x77. Jumper closed (SDO to GND) = 0x76.", + "addresses": {"0x77": "default (SDO high/open)", "0x76": "ADDR jumper closed / SDO to GND"}, + "guide_url": "https://learn.adafruit.com/adafruit-bme280-humidity-barometric-pressure-temperature-sensor-breakout", + "confidence": "high" + }, + "bme680": { + "default_address": "0x77", + "has_address_jumper": true, + "address_jumper_info": "Wire SDO to GND to change address. Default = 0x77. SDO to GND = 0x76.", + "addresses": {"0x77": "default (SDO high/open)", "0x76": "SDO to GND"}, + "guide_url": "https://learn.adafruit.com/adafruit-bme680-humidity-temperature-barometic-pressure-voc-gas", + "confidence": "high" + }, + "bme688": { + "default_address": "0x77", + "has_address_jumper": true, + "address_jumper_info": "Same as BME680 — wire SDO to GND. Default = 0x77. SDO to GND = 0x76.", + "addresses": {"0x77": "default (SDO high/open)", "0x76": "SDO to GND"}, + "guide_url": "https://learn.adafruit.com/adafruit-bme680-humidity-temperature-barometic-pressure-voc-gas", + "confidence": "high" + }, + "bmp280": { + "default_address": "0x77", + "has_address_jumper": true, + "address_jumper_info": "SDO pin controls address. Default (SDO high) = 0x77. SDO to GND = 0x76. Older board revision exposes SDO pin; newer has solder jumper.", + "addresses": {"0x77": "default (SDO high)", "0x76": "SDO to GND"}, + "guide_url": "https://learn.adafruit.com/adafruit-bmp280-barometric-pressure-plus-temperature-sensor-breakout", + "confidence": "high" + }, + "bmp388": { + "default_address": "0x77", + "has_address_jumper": true, + "address_jumper_info": "ADDR solder jumper on board. Default (open) = 0x77. Jumper closed = 0x76.", + "addresses": {"0x77": "default (ADDR open)", "0x76": "ADDR jumper closed"}, + "guide_url": "https://learn.adafruit.com/adafruit-bmp388-bmp390-bmp3xx", + "confidence": "high" + }, + "bmp390": { + "default_address": "0x77", + "has_address_jumper": true, + "address_jumper_info": "ADDR solder jumper on board. Default (open) = 0x77. Jumper closed = 0x76. Same guide as BMP388.", + "addresses": {"0x77": "default (ADDR open)", "0x76": "ADDR jumper closed"}, + "guide_url": "https://learn.adafruit.com/adafruit-bmp388-bmp390-bmp3xx", + "confidence": "high" + }, + "bmp580": { + "default_address": "0x47", + "has_address_jumper": true, + "address_jumper_info": "SDO pin controls address. Default (SDO high) = 0x47. SDO to GND = 0x46. Bosch BMP5xx family.", + "addresses": {"0x47": "default (SDO high)", "0x46": "SDO to GND"}, + "guide_url": null, + "confidence": "high" + }, + "bmp581": { + "default_address": "0x47", + "has_address_jumper": true, + "address_jumper_info": "SDO pin controls address. Default (SDO high) = 0x47. SDO to GND = 0x46. Bosch BMP5xx family.", + "addresses": {"0x47": "default (SDO high)", "0x46": "SDO to GND"}, + "guide_url": null, + "confidence": "high" + }, + "bmp585": { + "default_address": "0x47", + "has_address_jumper": true, + "address_jumper_info": "SDO pin controls address. Default (SDO high) = 0x47. SDO to GND = 0x46. Bosch BMP5xx family.", + "addresses": {"0x47": "default (SDO high)", "0x46": "SDO to GND"}, + "guide_url": null, + "confidence": "high" + }, + "d6t1a": { + "default_address": "0x0A", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x0A. OMRON thermal sensor.", + "addresses": {"0x0A": "fixed"}, + "guide_url": null, + "confidence": "high" + }, + "dht20": { + "default_address": "0x38", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x38, cannot be changed. ASAIR sensor.", + "addresses": {"0x38": "fixed"}, + "guide_url": null, + "confidence": "high" + }, + "dps310": { + "default_address": "0x77", + "has_address_jumper": true, + "address_jumper_info": "SDO/ADR solder jumper on back. Default (open) = 0x77. Bridge jumper (SDO to GND) = 0x76.", + "addresses": {"0x77": "default (SDO open)", "0x76": "SDO jumper bridged"}, + "guide_url": "https://learn.adafruit.com/adafruit-dps310-precision-barometric-pressure-sensor", + "confidence": "high" + }, + "ds2484": { + "default_address": "0x18", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x18. Maxim 1-Wire to I2C bridge.", + "addresses": {"0x18": "fixed"}, + "guide_url": null, + "confidence": "high" + }, + "ens160": { + "default_address": "0x53", + "has_address_jumper": true, + "address_jumper_info": "One solder jumper on back labeled 'Addr', plus SDO pin. Default (open) = 0x53. Closed (SDO to GND) = 0x52.", + "addresses": {"0x53": "default (ADDR open)", "0x52": "ADDR closed / SDO to GND"}, + "guide_url": "https://learn.adafruit.com/adafruit-ens160-mox-gas-sensor", + "confidence": "high" + }, + "ens161": { + "default_address": "0x53", + "has_address_jumper": true, + "address_jumper_info": "One solder jumper on back labeled 'Addr', plus SDO pin. Default (open) = 0x53. Closed (SDO to GND) = 0x52.", + "addresses": {"0x53": "default (ADDR open)", "0x52": "ADDR closed / SDO to GND"}, + "guide_url": "https://learn.adafruit.com/adafruit-ens161-mox-gas-sensor", + "confidence": "high" + }, + "hdc302x": { + "default_address": "0x44", + "has_address_jumper": true, + "address_jumper_info": "Two solder jumpers on back: A0 and A1. Address = 0x44 + A0(1) + A1(2). Both open: 0x44 (default). A0 closed: 0x45. A1 closed: 0x46. Both closed: 0x47.", + "addresses": {"0x44": "default (A0 open, A1 open)", "0x45": "A0 closed", "0x46": "A1 closed", "0x47": "A0+A1 closed"}, + "guide_url": "https://learn.adafruit.com/adafruit-hdc3021-precision-temperature-humidity-sensor", + "confidence": "high" + }, + "hts221": { + "default_address": "0x5F", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x5F, cannot be changed.", + "addresses": {"0x5F": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-hts221-temperature-humidity-sensor", + "confidence": "high" + }, + "htu21d": { + "default_address": "0x40", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x40. Guide states: 'you can't change it!' Use multiplexer for multiple devices.", + "addresses": {"0x40": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-htu21d-f-temperature-humidity-sensor", + "confidence": "high" + }, + "htu31d": { + "default_address": "0x40", + "has_address_jumper": true, + "address_jumper_info": "ADDR pin selects address. Default (low) = 0x40. ADDR to VDD = 0x41. Two addresses available.", + "addresses": {"0x40": "default (ADDR low)", "0x41": "ADDR to VDD"}, + "guide_url": null, + "confidence": "high" + }, + "ina219": { + "default_address": "0x40", + "has_address_jumper": true, + "address_jumper_info": "Two solder jumpers: A0 and A1. Bridging pulls pin to VIN. Four addresses: 0x40 (both open), 0x41 (A0), 0x44 (A1), 0x45 (A0+A1).", + "addresses": {"0x40": "default (A0 open, A1 open)", "0x41": "A0 closed", "0x44": "A1 closed", "0x45": "A0+A1 closed"}, + "guide_url": "https://learn.adafruit.com/adafruit-ina219-current-sensor-breakout", + "confidence": "high" + }, + "ina228": { + "default_address": "0x40", + "has_address_jumper": true, + "address_jumper_info": "Two solder jumpers on back: A0 and A1. Four addresses: 0x40 (both open), 0x41 (A0), 0x44 (A1), 0x45 (A0+A1). TI chip supports 16 but Adafruit board exposes 4.", + "addresses": {"0x40": "default (A0 open, A1 open)", "0x41": "A0 closed", "0x44": "A1 closed", "0x45": "A0+A1 closed"}, + "guide_url": "https://learn.adafruit.com/adafruit-ina228-i2c-power-monitor", + "confidence": "high" + }, + "ina237": { + "default_address": "0x40", + "has_address_jumper": true, + "address_jumper_info": "Two solder jumpers on back: A0 and A1. Four addresses: 0x40 (both open), 0x41 (A0), 0x44 (A1), 0x45 (A0+A1).", + "addresses": {"0x40": "default (A0 open, A1 open)", "0x41": "A0 closed", "0x44": "A1 closed", "0x45": "A0+A1 closed"}, + "guide_url": "https://learn.adafruit.com/adafruit-ina237-dc-current-voltage-power-monitor", + "confidence": "high" + }, + "ina238": { + "default_address": "0x40", + "has_address_jumper": true, + "address_jumper_info": "Two solder jumpers on back: A0 and A1. Four addresses: 0x40 (both open), 0x41 (A0), 0x44 (A1), 0x45 (A0+A1). Covered under INA237 guide.", + "addresses": {"0x40": "default (A0 open, A1 open)", "0x41": "A0 closed", "0x44": "A1 closed", "0x45": "A0+A1 closed"}, + "guide_url": "https://learn.adafruit.com/adafruit-ina237-dc-current-voltage-power-monitor", + "confidence": "high" + }, + "ina260": { + "default_address": "0x40", + "has_address_jumper": true, + "address_jumper_info": "Two solder jumpers: A0 and A1. Bridging pulls pin to VCC. Four addresses: 0x40 (both open), 0x41 (A0), 0x44 (A1), 0x45 (A0+A1).", + "addresses": {"0x40": "default (A0 open, A1 open)", "0x41": "A0 closed", "0x44": "A1 closed", "0x45": "A0+A1 closed"}, + "guide_url": "https://learn.adafruit.com/adafruit-ina260-current-voltage-power-sensor-breakout", + "confidence": "high" + }, + "lc709203f": { + "default_address": "0x0B", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x0B. Guide states: 'uses I2C address 0x0B and it cannot be changed.'", + "addresses": {"0x0B": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-lc709203f-lipo-lipoly-battery-monitor", + "confidence": "high" + }, + "lps22hb": { + "default_address": "0x5D", + "has_address_jumper": true, + "address_jumper_info": "SDO pin selects address. Default (SDO floating/high) = 0x5D. Pull SDO to GND = 0x5C.", + "addresses": {"0x5D": "default (SDO high/floating)", "0x5C": "SDO to GND"}, + "guide_url": "https://learn.adafruit.com/adafruit-lps25-pressure-sensor", + "confidence": "high" + }, + "lps25hb": { + "default_address": "0x5D", + "has_address_jumper": true, + "address_jumper_info": "SDO pin selects address. Default (SDO floating/high) = 0x5D. Pull SDO to GND = 0x5C.", + "addresses": {"0x5D": "default (SDO high/floating)", "0x5C": "SDO to GND"}, + "guide_url": "https://learn.adafruit.com/adafruit-lps25-pressure-sensor", + "confidence": "high" + }, + "lps28dfw": { + "default_address": "0x5C", + "has_address_jumper": true, + "address_jumper_info": "SA0 solder jumper on back. Default (open) = 0x5C. Close SA0 jumper = 0x5D.", + "addresses": {"0x5C": "default (SA0 open)", "0x5D": "SA0 jumper closed"}, + "guide_url": "https://learn.adafruit.com/adafruit-lps28-pressure-sensor", + "confidence": "high" + }, + "lps33hw": { + "default_address": "0x5D", + "has_address_jumper": true, + "address_jumper_info": "SDO pin selects address. Default (no jumper) = 0x5D. Jumper SDO to GND = 0x5C.", + "addresses": {"0x5D": "default (SDO high)", "0x5C": "SDO to GND"}, + "guide_url": "https://learn.adafruit.com/lps35hw-water-resistant-pressure-sensor", + "confidence": "high" + }, + "lps35hw": { + "default_address": "0x5D", + "has_address_jumper": true, + "address_jumper_info": "SDO pin selects address. Default (no jumper) = 0x5D. Jumper SDO to GND = 0x5C.", + "addresses": {"0x5D": "default (SDO high)", "0x5C": "SDO to GND"}, + "guide_url": "https://learn.adafruit.com/lps35hw-water-resistant-pressure-sensor", + "confidence": "high" + }, + "ltr303": { + "default_address": "0x29", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x29, cannot be changed.", + "addresses": {"0x29": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-ltr-329-ltr-303", + "confidence": "high" + }, + "ltr329": { + "default_address": "0x29", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x29, cannot be changed.", + "addresses": {"0x29": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-ltr-329-ltr-303", + "confidence": "high" + }, + "ltr390": { + "default_address": "0x53", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x53, cannot be changed.", + "addresses": {"0x53": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-ltr390-uv-sensor", + "confidence": "high" + }, + "max17048": { + "default_address": "0x36", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x36, cannot be changed.", + "addresses": {"0x36": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-max17048-lipoly-liion-fuel-gauge-and-battery-monitor", + "confidence": "high" + }, + "max44009": { + "default_address": "0x4A", + "has_address_jumper": true, + "address_jumper_info": "Addr solder jumper on bottom of breakout. Default (open) = 0x4A. Solder closed = 0x4B.", + "addresses": {"0x4A": "default (ADDR open)", "0x4B": "ADDR jumper closed"}, + "guide_url": "https://learn.adafruit.com/adafruit-max44009-lux-light-sensor", + "confidence": "high" + }, + "mcp3421": { + "default_address": "0x68", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x68. Guide recommends TCA9548A multiplexer for multiple devices.", + "addresses": {"0x68": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-mcp3421-18-bit-adc", + "confidence": "high" + }, + "mcp9601": { + "default_address": "0x67", + "has_address_jumper": true, + "address_jumper_info": "Two solder jumpers on back: '43k' and '22k'. Closing 43k = 0x66. Closing 22k = 0x65. Additional addresses 0x60-0x64 via resistor on ADDR pin (see datasheet Table 6-2). Default ADDR floating = 0x67.", + "addresses": {"0x67": "default (ADDR floating)", "0x66": "43k jumper closed", "0x65": "22k jumper closed", "0x64": "both jumpers closed", "0x60-0x63": "via external resistor on ADDR pin"}, + "guide_url": "https://learn.adafruit.com/adafruit-mcp9601", + "confidence": "high" + }, + "mcp9808": { + "default_address": "0x18", + "has_address_jumper": true, + "address_jumper_info": "Three address pins: A0, A1, A2 with pull-down resistors. Connect to VDD to set high (read on power-up, must power cycle). Address = 0x18 + A0(1) + A1(2) + A2(4). Eight addresses: 0x18-0x1F.", + "addresses": {"0x18": "default (all open)", "0x19": "A0", "0x1A": "A1", "0x1B": "A0+A1", "0x1C": "A2", "0x1D": "A0+A2", "0x1E": "A1+A2", "0x1F": "A0+A1+A2"}, + "guide_url": "https://learn.adafruit.com/adafruit-mcp9808-precision-i2c-temperature-sensor-guide", + "confidence": "high" + }, + "mlx90632b": { + "default_address": "0x3A", + "has_address_jumper": false, + "address_jumper_info": "Default address 0x3A. Alternate 0x3B may be available via configuration. Melexis IR thermometer.", + "addresses": {"0x3A": "default", "0x3B": "alternate"}, + "guide_url": null, + "confidence": "low" + }, + "mlx90632d_ext": { + "default_address": "0x3A", + "has_address_jumper": false, + "address_jumper_info": "Same hardware as MLX90632B in extended mode. Default address 0x3A.", + "addresses": {"0x3A": "default", "0x3B": "alternate"}, + "guide_url": null, + "confidence": "low" + }, + "mlx90632d_med": { + "default_address": "0x3A", + "has_address_jumper": false, + "address_jumper_info": "Same hardware as MLX90632B in medical mode. Default address 0x3A.", + "addresses": {"0x3A": "default", "0x3B": "alternate"}, + "guide_url": null, + "confidence": "low" + }, + "mpl115a2": { + "default_address": "0x60", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x60, cannot be changed.", + "addresses": {"0x60": "fixed"}, + "guide_url": null, + "confidence": "high" + }, + "mprls": { + "default_address": "0x18", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x18, cannot be changed. No address jumpers.", + "addresses": {"0x18": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-mprls-ported-pressure-sensor-breakout", + "confidence": "high" + }, + "ms8607": { + "default_address": "0x76", + "has_address_jumper": false, + "address_jumper_info": "Two fixed addresses (two devices in one package): pressure sensor at 0x76, humidity sensor at 0x40. Both are fixed and cannot be changed.", + "addresses": {"0x76": "pressure sensor (fixed)", "0x40": "humidity sensor (fixed)"}, + "guide_url": "https://learn.adafruit.com/adafruit-te-ms8607-pht-sensor", + "confidence": "high" + }, + "nau7802": { + "default_address": "0x2A", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x2A. Guide states: 'this sensor has a fixed I2C address of 0x2A.'", + "addresses": {"0x2A": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-nau7802-24-bit-adc-stemma-qt-qwiic", + "confidence": "high" + }, + "pct2075": { + "default_address": "0x37", + "has_address_jumper": true, + "address_jumper_info": "Three address pins A0, A1, A2 each support three states (high, low, floating) giving 27 possible addresses. All three float by default = 0x37. Solder jumpers on back connect each pin to GND or VCC. See guide for full 27-address map.", + "addresses": {"0x37": "default (all floating)", "0x28-0x2F": "various A0/A1/A2 combos", "0x35-0x37": "various", "0x48-0x4F": "various", "0x70-0x77": "various"}, + "guide_url": "https://learn.adafruit.com/adafruit-pct2075-temperature-sensor", + "confidence": "high" + }, + "pmsa003i": { + "default_address": "0x12", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x12, cannot be changed.", + "addresses": {"0x12": "fixed"}, + "guide_url": "https://learn.adafruit.com/pmsa003i", + "confidence": "high" + }, + "qmc5883p": { + "default_address": "0x2C", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x2C, no jumper pads.", + "addresses": {"0x2C": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-qmc5883p-triple-axis-magnetometer", + "confidence": "high" + }, + "rotary_encoder": { + "default_address": "0x36", + "has_address_jumper": true, + "address_jumper_info": "Three jumper pads on back: A0, A1, A2. Address = 0x36 + A0(1) + A1(2) + A2(4). All open: 0x36. Supports 8 addresses: 0x36-0x3D.", + "addresses": {"0x36": "default (all open)", "0x37": "A0", "0x38": "A1", "0x39": "A0+A1", "0x3A": "A2", "0x3B": "A0+A2", "0x3C": "A1+A2", "0x3D": "A0+A1+A2"}, + "guide_url": "https://learn.adafruit.com/adafruit-i2c-qt-rotary-encoder", + "confidence": "high" + }, + "scd30": { + "default_address": "0x61", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x61, cannot be changed.", + "addresses": {"0x61": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-scd30", + "confidence": "high" + }, + "scd40": { + "default_address": "0x62", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x62, cannot be changed.", + "addresses": {"0x62": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-scd-40-and-scd-41", + "confidence": "high" + }, + "sen50": { + "default_address": "0x69", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x69. Sensirion SEN5x family.", + "addresses": {"0x69": "fixed"}, + "guide_url": null, + "confidence": "high" + }, + "sen54": { + "default_address": "0x69", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x69. Sensirion SEN5x family.", + "addresses": {"0x69": "fixed"}, + "guide_url": null, + "confidence": "high" + }, + "sen55": { + "default_address": "0x69", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x69. Sensirion SEN5x family.", + "addresses": {"0x69": "fixed"}, + "guide_url": null, + "confidence": "high" + }, + "sen5x": { + "default_address": "0x69", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x69. Sensirion SEN5x family (generic).", + "addresses": {"0x69": "fixed"}, + "guide_url": null, + "confidence": "high" + }, + "sen66": { + "default_address": "0x6B", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x6B. Sensirion SEN66.", + "addresses": {"0x6B": "fixed"}, + "guide_url": null, + "confidence": "high" + }, + "sgp30": { + "default_address": "0x58", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x58. Guide explicitly states address cannot be changed.", + "addresses": {"0x58": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-sgp30-gas-tvoc-eco2-mox-sensor", + "confidence": "high" + }, + "sgp40": { + "default_address": "0x59", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x59, cannot be changed.", + "addresses": {"0x59": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-sgp40", + "confidence": "high" + }, + "sgp41": { + "default_address": "0x59", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x59, cannot be changed.", + "addresses": {"0x59": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-sgp41-multi-pixel-gas-sensor-breakout", + "confidence": "high" + }, + "sht20": { + "default_address": "0x40", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x40, cannot be changed. Sensirion sensor.", + "addresses": {"0x40": "fixed"}, + "guide_url": null, + "confidence": "high" + }, + "sht30_mesh": { + "default_address": "0x44", + "has_address_jumper": false, + "address_jumper_info": "Weatherproof mesh SHT30. ADR pin with 10K pull-down sets default 0x44. Tie ADR to Vin for 0x45. Uses breakout pin, not solder jumper.", + "addresses": {"0x44": "default (ADR low)", "0x45": "ADR to Vin"}, + "guide_url": "https://learn.adafruit.com/adafruit-sht31-d-temperature-and-humidity-sensor-breakout", + "confidence": "high" + }, + "sht30_shell": { + "default_address": "0x44", + "has_address_jumper": false, + "address_jumper_info": "Enclosed shell SHT30. ADR pin with 10K pull-down sets default 0x44. Tie ADR to Vin for 0x45. Uses breakout pin, not solder jumper.", + "addresses": {"0x44": "default (ADR low)", "0x45": "ADR to Vin"}, + "guide_url": "https://learn.adafruit.com/adafruit-sht31-d-temperature-and-humidity-sensor-breakout", + "confidence": "high" + }, + "sht3x": { + "default_address": "0x44", + "has_address_jumper": false, + "address_jumper_info": "ADR pin with 10K pull-down sets default 0x44. Tie ADR to Vin for 0x45. Uses breakout pin, not solder jumper.", + "addresses": {"0x44": "default (ADR low)", "0x45": "ADR to Vin"}, + "guide_url": "https://learn.adafruit.com/adafruit-sht31-d-temperature-and-humidity-sensor-breakout", + "confidence": "high" + }, + "sht40": { + "default_address": "0x44", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x44. Guide states: 'cannot be changed (a manufacturer limitation).' Use PCA9546/TCA9548A multiplexer.", + "addresses": {"0x44": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-sht40-temperature-humidity-sensor", + "confidence": "high" + }, + "sht41": { + "default_address": "0x44", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x44, cannot be changed (manufacturer limitation). Same as SHT40.", + "addresses": {"0x44": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-sht40-temperature-humidity-sensor", + "confidence": "high" + }, + "sht45": { + "default_address": "0x44", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x44, cannot be changed (manufacturer limitation). Same as SHT40.", + "addresses": {"0x44": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-sht40-temperature-humidity-sensor", + "confidence": "high" + }, + "shtc3": { + "default_address": "0x70", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x70, cannot be changed.", + "addresses": {"0x70": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-sensirion-shtc3-temperature-humidity-sensor", + "confidence": "high" + }, + "si7021": { + "default_address": "0x40", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x40. Guide states: 'you can't change it!'", + "addresses": {"0x40": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-si7021-temperature-plus-humidity-sensor", + "confidence": "high" + }, + "spa06_003": { + "default_address": "0x77", + "has_address_jumper": true, + "address_jumper_info": "One ADDR jumper pad (SDO pin) on back. Default (open) = 0x77. Solder closed (SDO to GND) = 0x76.", + "addresses": {"0x77": "default (ADDR open)", "0x76": "ADDR jumper closed / SDO to GND"}, + "guide_url": "https://learn.adafruit.com/adafruit-spa06-003-temperature-pressure-sensor", + "confidence": "high" + }, + "stemma_soil": { + "default_address": "0x36", + "has_address_jumper": true, + "address_jumper_info": "Two address jumper pads: AD0 and AD1. AD0 adds 1, AD1 adds 2 to base. Neither: 0x36. AD0 only: 0x37. AD1 only: 0x38. Both: 0x39. Supports 4 sensors.", + "addresses": {"0x36": "default (AD0 open, AD1 open)", "0x37": "AD0 closed", "0x38": "AD1 closed", "0x39": "AD0+AD1 closed"}, + "guide_url": "https://learn.adafruit.com/adafruit-stemma-soil-sensor-i2c-capacitive-moisture-sensor", + "confidence": "high" + }, + "tc74a0": { + "default_address": "0x48", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x48. TC74A0 variant has address hardcoded in part number. Other TC74 variants have different fixed addresses.", + "addresses": {"0x48": "fixed"}, + "guide_url": null, + "confidence": "high" + }, + "tmp117": { + "default_address": "0x48", + "has_address_jumper": true, + "address_jumper_info": "ADDR/AD0 solder jumper on back. Default (open/pin low) = 0x48. Jumper closed (pin high) = 0x49.", + "addresses": {"0x48": "default (ADDR open)", "0x49": "ADDR jumper closed"}, + "guide_url": "https://learn.adafruit.com/adafruit-tmp117-high-accuracy-i2c-temperature-monitor", + "confidence": "high" + }, + "tmp119": { + "default_address": "0x48", + "has_address_jumper": true, + "address_jumper_info": "ADDR solder jumper on back. Open (low) = 0x48 (default). Closed (high) = 0x49.", + "addresses": {"0x48": "default (ADDR open)", "0x49": "ADDR jumper closed"}, + "guide_url": "https://learn.adafruit.com/adafruit-tmp119-high-precision-temperature-sensor", + "confidence": "high" + }, + "tsl2591": { + "default_address": "0x29", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x29, cannot be changed. Use I2C multiplexer for multiple sensors.", + "addresses": {"0x29": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-tsl2591", + "confidence": "high" + }, + "vcnl4020": { + "default_address": "0x13", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x13, cannot be changed.", + "addresses": {"0x13": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-vcnl4020-proximity-and-light-sensor", + "confidence": "high" + }, + "vcnl4040": { + "default_address": "0x60", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x60, cannot be changed.", + "addresses": {"0x60": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-vcnl4040-proximity-sensor", + "confidence": "high" + }, + "vcnl4200": { + "default_address": "0x51", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x51, cannot be changed.", + "addresses": {"0x51": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-vcnl4200-long-distance-ir-proximity-and-light-sensor", + "confidence": "high" + }, + "veml7700": { + "default_address": "0x10", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x10, cannot be changed.", + "addresses": {"0x10": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-veml7700", + "confidence": "high" + }, + "vl53l0x": { + "default_address": "0x29", + "has_address_jumper": false, + "address_jumper_info": "No hardware jumper. Address changeable in software via XSHUT pin. Default 0x29. Hold in reset via XSHUT, then call setAddress() to assign any address <0x7F. Resets on power cycle.", + "addresses": {"0x29": "default (software-configurable via XSHUT)"}, + "guide_url": "https://learn.adafruit.com/adafruit-vl53l0x-micro-lidar-distance-sensor-breakout", + "confidence": "high" + }, + "vl53l1x": { + "default_address": "0x29", + "has_address_jumper": false, + "address_jumper_info": "No hardware jumper. Address changeable in software via XSHUT pin. Default 0x29. Use XSHUT to hold in shutdown, then set_address(). Resets on power cycle.", + "addresses": {"0x29": "default (software-configurable via XSHUT)"}, + "guide_url": "https://learn.adafruit.com/adafruit-vl53l1x", + "confidence": "high" + }, + "vl53l4cd": { + "default_address": "0x29", + "has_address_jumper": false, + "address_jumper_info": "No hardware jumper. Has XSHUT pin. Guide doesn't document software address change but chip supports it (same ST VL53 family).", + "addresses": {"0x29": "default (XSHUT pin available)"}, + "guide_url": "https://learn.adafruit.com/adafruit-vl53l4cd-time-of-flight-distance-sensor", + "confidence": "high" + }, + "vl53l4cx": { + "default_address": "0x29", + "has_address_jumper": false, + "address_jumper_info": "No hardware jumper. Has XSHUT pin. Guide doesn't document software address change but chip supports it (same ST VL53 family).", + "addresses": {"0x29": "default (XSHUT pin available)"}, + "guide_url": "https://learn.adafruit.com/adafruit-vl53l4cx-time-of-flight-distance-sensor", + "confidence": "high" + }, + "vl6180x": { + "default_address": "0x29", + "has_address_jumper": false, + "address_jumper_info": "Fixed address 0x29. Guide states: 'you can't change it'. Has XSHUT/SHDN pin for shutdown only.", + "addresses": {"0x29": "fixed"}, + "guide_url": "https://learn.adafruit.com/adafruit-vl6180x-time-of-flight-micro-lidar-distance-sensor-breakout", + "confidence": "high" + } + } +} diff --git a/opencode.md b/opencode.md new file mode 100644 index 00000000..2c8a9cfa --- /dev/null +++ b/opencode.md @@ -0,0 +1 @@ +See @agents.md