Skip to the content
PCBKitNew board
2 layers62.3 × 30.3 mmLumary brain, the printed circuit board in 3D

ESP32-H2 Zigbee firmware replacing Tuya WiFi in Lumary 6" RGBAI recessed lights — hub-independent control, 8 effects, OTA updates

Size
62.3 × 30.3 mm
Copper layers
2
Thickness
1.6 mm
Parts
32
Nets
23
Pads
168
Tracks, vias
198, 25
Pours
2
Routed
Yes
KiCad
KiCad 10.0 (file 20260206)
Created
29 Mar 2026
Updated
25 Sep 2026

Overview

Lumary brain is an open-source printed circuit board by michaudtime, from the michaudtime/lumary-zigbee repository on GitHub. It is a 2-layer board of 62.3 × 30.3 mm with 32 parts on 23 nets, routed with 198 tracks and 25 vias.

It is built around the 74AHCT1G125SE, ESP32-H2-MINI-1-N4 and ME6211C33M5G-N, with the USBLC6-2SC6, B5819W and AO3401A among its other parts. Its parts include 9 resistors, 8 capacitors, 4 ICs, 3 diodes and 3 transistors. It belongs with the radio and wireless boards and the ESP32 and RISC-V boards of this library.

Main components

7

README

README.md

Lumary Zigbee Controller

Replaces the Tuya WiFi controller inside a Lumary 6" RGBAI recessed light with an ESP32-H2 Zigbee controller, enabling:

  • Direct Zigbee binding to Inovelli Blue Series switches (hub-independent on/off, dimming, scene control)
  • 6 built-in lighting effects on the accent ring, with per-scene parameters stored on-device
  • Zigbee OTA firmware updates via Zigbee2MQTT

Hardware

ComponentPart
Controllerlumary-brain rev A — custom ESP32-H2-MINI-1 board, drop-in for the stock KOK-AH-A172C (see hardware/)
Target lightLumary Smart RGBAI Recessed Light 6"
SwitchInovelli Blue Series 2-1 Zigbee

The two light sources

The fixture has two independent sources, and the firmware drives them as two separate Zigbee endpoints, so Home Assistant sees two independent light entities rather than one:

SourceHardwareDriven by
Outer "gradient" ring62-pixel 5 V 3535 RGBIC strip, 3 colour bytes/pixel (no white die)One data line, NZR over SPI
Inner white ring96 LEDs, 12 series × 4 parallel per colour, 2700 K + 6500 KTwo low-side PWM channels steering the driver's 380 mA

The external L-SD8E1 driver supplies 36.63 V constant-current (inner white anode) and 4.7 V (ring + logic). The board never sources the white current — it gates and steers it.

EntityEndpointControls
light.<name>_downlight1on/off, brightness, colour temperature 2700–6500 K
light.<name>_ring2on/off, brightness, xy colour, the six effects

Upgrading an already-paired fixture: adding the second endpoint changes the device descriptor, and Zigbee2MQTT caches endpoints from the interview, so an already-paired fixture must be re-interviewed (Z2M frontend → device → Re-interview, or publish to zigbee2mqtt/bridge/request/device/interview) before the ring entity works. This has never been observed on hardware, so exactly what you'll see beforehand is unconfirmed either way -- expect the ring entity to be missing entirely, or present but unresponsive, until the re-interview is done. Newly paired fixtures just work.

It is also no longer easy to check: both fixtures in service have already crossed this descriptor change, and a fixture that has crossed it cannot show you the before. Settling it would mean deliberately pairing a board on pre-2.0.0 firmware and upgrading it while watching -- worth doing when the remaining rev A boards are brought up, or this hedge should simply stay as written.

Wiring (rev A board)

ESP32-H2 PinSignalDestination
GPIO 11 (SPI2 MOSI)Ring data → 3.3→4.7 V bufferCN1.DIM (ring DIN)
GPIO 4PWM cold white → Q1 gateCN1.CW- (6500 K return)
GPIO 5PWM warm white → Q2 gateCN1.WW- (2700 K return)
GPIO 9BOOT buttonBench download / BLE OTA

Pin note: GPIO 2/3/8/9/25 are ESP32-H2 strapping pins. The white PWM channels use GPIO 4/5 on rev A so the MOSFET gate pulldowns can't hold a strapping pin low at reset — the Super Mini prototype's GPIO 2/3 assignment does not carry over.

Level shifting: the ring runs on the 4.7 V rail, so its data line is buffered up from 3.3 V by a 74AHCT1G125 (AHCT accepts 3.3 V as a logic high).

Why SPI instead of RMT for the ring?

On ESP32-H2, the RMT peripheral conflicts with the Zigbee radio. Instead, SPI2 bit-encodes the single-wire NZR protocol: each NZR bit becomes 3 SPI bits (1 → 110, 0 → 100) at 2.4 MHz, producing a valid 800 kHz signal on MOSI. The encoder lives in src/pixel_encode.h, deliberately free of ESP-IDF headers so it can be unit-tested on the host.

Built-in Effects

These belong to the Accent Ring entity only — the Downlight has no effects, just on/off, brightness and colour temperature.

#NameDescription
1Warm GradientWarm→cool sweep rotating around the ring
2Color GradientMulti-hue gradient rotating around ring
3BreathingOuter ring pulses in/out
4Color CycleFull HSV hue rotation
5ChaseSingle lit segment travels around ring
6NightlightDim warm white outer ring only

Changed in this version: static_white and static_color are gone from the effect list. White is now the separate Downlight entity, and a solid ring colour is effect: none with a colour set. Automations naming either of the two removed effects need updating. Stored scenes are reseeded automatically — the NVS schema version bump discards the old indices rather than misreading them.

Switch Control (Hub-Independent)

Inovelli actionBehavior
1× tapOn/Off
Hold up/downBrightness
2× tap upNext effect — via hub automation, see below
2× tap downPrevious effect — via hub automation

On/off and dimming use direct Zigbee binding — no hub required, and they keep working if the coordinator is down. With two fixture endpoints, the switch binds to both:

text
Bind from the Inovelli's endpoint 2 (the paddle) to BOTH fixture endpoints,
clusters genOnOff and genLevelCtrl:

  switch ep2 -> fixture ep1   (downlight)
  switch ep2 -> fixture ep2   (accent ring)

One tap down turns both sources off; one tap up brings both back at their own levels and colours. This works because the Inovelli sends discrete On/Off rather than Toggle — verified on the hardware. Under Toggle, two endpoints that had drifted into different states would diverge further on every tap instead of converging. "Downlight only" is a Home Assistant action rather than a switch action; remove the second binding if you would rather the ring ignored the switch.

Effect stepping is not hub-independent, and cannot be. The Inovelli's multi-tap events are manufacturer-specific and go to the coordinator rather than to a bound light, so nothing the light does could make 2× tap work over pure binding. The automation reads the current effect_select and writes the next one. Not yet verified end to end (plan Task 6.4).

Selecting an effect

Effect selection rides a manufacturer-specific cluster (0xFC00), because nothing standard can carry it: the Scenes cluster stores colour and level but knows nothing about an effect type, and the Identify trigger-effects have no hook in the Arduino Zigbee library.

Install z2m/lumary-brain-revA.js into Z2M's data/external_converters/ and restart. Without the converter the light still works normally — the effects are simply unreachable.

The converter exposes the effects as the Accent Ring entity's native Home Assistant effect list, so they appear in the effect dropdown of the ring's light card itself rather than as a separate entity:

yaml
service: light.turn_on
target: {entity_id: light.lumary_kitchen_ring}
data: {effect: color_cycle}

That also means HA scene snapshots capture the running effect, and voice assistants can select one. The current effect reads back as state_attr('light.lumary_kitchen_ring', 'effect').

ValueMeaning
warm_gradient … nightlightOne of the six built-in effects, in EffectType order
noneNot running an effect — showing a plain colour

Setting a colour on the ring exits the effect and shows that colour instead, which reads back as none. Selecting none does the same thing without changing the colour. Brightness continues to scale whatever effect is running.

The selected effect persists across power cuts (NVS); none deliberately does not, so a power cycle comes back to the stored effect.

The Identify trigger-effects that Z2M's stock effect dropdown drives (blink, breathe, okay, …) are switched off in the converter, along with power_on_behavior. Both are on by default in light(), and this firmware implements neither — leaving them on would put controls in the light card that quietly do nothing. See effect: false / powerOnBehavior: false there.

Stepping through effects from the wall switch is done by a hub automation, not by the light: read the current effect and set the next one. The Inovelli's multi-tap events go to the coordinator rather than to a bound light, so a hub is in the loop regardless.

Scene storage exists — 16 NVS slots, seeded with the defaults in src/effect_params.h — but editing them is not implemented. There is no Add Scene support; scene_store_save() is only called to seed defaults at first boot. Changing an effect's colour, speed or brightness permanently is a future change.

OTA Updates

Re-flashing a fixture that was already paired? See "Upgrading an already-paired fixture" above — after this update it needs a Z2M re-interview before the ring entity works, or the upgrade will look broken even though it succeeded.

Primary: Zigbee OTA via Zigbee2MQTT.

Three things must all be true, and each one fails differently:

RequirementSymptom when missing
Firmware registers an OTA clientDevice never asks; nothing in the Z2M log
Converter declares ota: trueNo update entity at all in Home Assistant
Z2M can find the image via an indexUpdate entity exists; update fails No image currently available

Z2M does not discover firmware by scanning a folder. Copying a .zigbee file into data/ota/ accomplishes nothing on its own — Z2M reads an index that describes the image, pointed at by ota.zigbee_ota_override_index_location in configuration.yaml. scripts/gen-ota-index.py generates one; see "Building an OTA image" below.

ota is a definition property, not a modern extend. There is no m.ota() in zigbee-herdsman-converters, and using one makes Z2M reject the entire converter as invalid at load time. It goes on the definition object alongside onEvent.

There is no fallback. A BLE OTA path was planned — PIN_BLE_OTA_BUTTON is still defined in src/config.h — but it was never implemented, and nothing references that pin. Do not count on it for a fixture already in the ceiling: if Zigbee OTA cannot reach a light, the only recovery is physical access to the USB port. Getting Zigbee OTA verified from an installed location is consequently the gate on signing off the board (plan task 6.4), not a nice-to-have.

Scheduled updates and automatic recovery (2.1.0 and later)

Start updates with schedule, not update:

text
topic:   zigbee2mqtt/bridge/request/device/ota_update/schedule
payload: {"id": "Loft Overhead Light"}

This means not using the Install button on Home Assistant's update card. That button is the ota_update/update path: Zigbee2MQTT pushes an image notify at the fixture and waits for it to answer, which for a fixture a floor away from the coordinator typically ends in OTA update of '<name>' failed (Device didn't respond to OTA request) — a failure that says nothing about the image or the index. schedule parks the request instead and lets the fixture collect it on its own next query, which is what the recovery loop below is built around. Seen 2026-09-21 on the loft fixture: Install failed this way, the same image then installed from a schedule.

check, update and schedule share one per-device lock, so a schedule sent while either of the others is still running is rejected with OTA update or check for update already in progress for '<name>'. Wait for the first to finish and send it again.

The fixture queries for an image shortly after it joins the network at boot, then hourly. A scheduled update starts on whichever of those queries comes next, and Zigbee2MQTT keeps it scheduled if it fails. Combined with the firmware's recovery this makes an update self-healing:

  1. If a download aborts, or stalls for 3 minutes, the fixture saves its light state, restarts (the light blinks for about a second, then comes back as it was), and rejoins.
  2. On rejoin it queries for an image, and the still-scheduled update starts again from the beginning.
  3. When it succeeds, the fixture restarts into the new version and comes back off -- light state is only carried across a recovery restart, not across the update's own restart -- and Zigbee2MQTT clears the schedule.

The restart itself backs off on repeated recoveries: the first two consecutive recoveries restart at once, but from the third the restart waits 5 minutes, doubling each time up to an hour, so an image that fails the same way every time cannot make a fixture blink every few seconds. It never stops retrying on its own -- unschedule is what stops it.

The restart is required: the Arduino Zigbee library never resets its OTA state after an abort, so without it every retry fails INVALID_IMAGE two blocks in. To stop a fixture retrying an image that can never succeed, send the same payload to .../ota_update/unschedule. Each recovery is logged on the serial console with a running count.

Fixtures on firmware older than 2.1.0 have no automatic recovery: after a failed update, power- cycle the fixture before retrying. From Home Assistant, via its Inovelli switch: turn the switch off, set its Smart Bulb Mode to Disabled (the switch is off, so the load loses power), wait ~15 s, then set Smart Bulb Mode back to Smart Bulb Mode (full power returns and the fixture boots).

Building an OTA image

The device registers an OTA client at startup and queries for an image once it joins, then hourly. The coordinator only offers images numbered above the running version, so the version block in src/version.h must be bumped and the same ZB_FW_VERSION passed as --file-version below — if they disagree, the update silently never appears.

The tool was renamed and its CLI changed. Espressif's tools/ota_image_tool.py is gone; it is now tools/image_builder_tool/image_builder_tool.py, it takes short flags rather than the old long ones, and the firmware is supplied as a --tag sub-element rather than a positional argument. There is no create subcommand, and the output filename is generated for you rather than given. Verified 2026-08-18.

bash
# Get the tool
mkdir -p build/ota && curl -sSL -o build/ota/image_builder_tool.py   https://raw.githubusercontent.com/espressif/esp-zigbee-sdk/main/tools/image_builder_tool/image_builder_tool.py

# Build firmware (bump the version block in src/version.h first)
pio run -e esp32h2

# Wrap as an OTA image. -m/-i come from src/config.h (ZB_MANUFACTURER_CODE,
# ZB_IMAGE_TYPE); -v must equal ZB_FW_VERSION. Tag 0x0000 is the upgrade image.
cd build/ota && python image_builder_tool.py   -m 0x1001   -i 0x0001   -v 0x02000000   -s 2   -g "LumaryZigbee"   --tag 0x0000 ../../.pio/build/esp32h2/firmware.bin

The output is named from the three identifiers rather than chosen — for the values above, 1001-0001-02000000-ota-file.zigbee.

Then generate the index Z2M actually reads. It takes the manufacturer code, image type, file version and header string out of the image itself rather than from arguments, so the index cannot disagree with the file it describes:

bash
python scripts/gen-ota-index.py build/ota/1001-0001-02000000-ota-file.zigbee

Copy both the .zigbee image and the generated index.json into Z2M's data/ota/ folder, then point configuration.yaml at the index and restart Z2M:

yaml
ota:
  zigbee_ota_override_index_location: ota/index.json

Both paths are relative to the Z2M data directory — the index's own location, and the url inside it naming the image. Verified end to end 2026-08-18 against Z2M 2.13.0: no http(s) URL and no absolute path is needed for a locally served image.

The url is relative to the data directory, not to the index that contains it. With the layout above the index lives at data/ota/index.json and the image sits beside it, so the url still needs the ota/ prefix — ota/foo.zigbee, not foo.zigbee. gen-ota-index.py adds it.

Getting this wrong is worth recognising, because it does not look like a path problem. ota_update/check reads index metadata only, so it reports update_available: true and Home Assistant offers the update quite happily. Only ota_update/update opens the file, and it reports the failure as No image currently available — the same message you get with no index at all. The real cause appears only in Z2M's debug log:

text
zh:controller:ota: Reading firmware image from 'foo.zigbee'
ENOENT: no such file or directory, open '/config/zigbee2mqtt/foo.zigbee'

Z2M caches the last OTA check, so after installing an index it will keep reporting the device as up to date until it re-checks — on the next device announce, hourly, or immediately if you ask:

text
topic:   zigbee2mqtt/bridge/request/device/ota_update/check
payload: {"id": "Overhead light test"}

For a one-off test you can skip the index entirely and name the file in the update request, which is useful for confirming the image itself is good before troubleshooting configuration:

text
topic:   zigbee2mqtt/bridge/request/device/ota_update/update
payload: {"id": "Overhead light test", "url": "ota/1001-0001-02000000-ota-file.zigbee"}

build/ is git-ignored, so neither the tool, the image, nor the index is committed.

Build & Flash

Re-flashing a fixture that was already paired? See "Upgrading an already-paired fixture" above — after this update it needs a Z2M re-interview before the ring entity works, or the upgrade will look broken even though it succeeded.

Windows: run pio from PowerShell or cmd, not Git Bash. This platform version installs its toolchain via idf_tools.py, which aborts with ERROR: MSys/Mingw is not supported under Git Bash. Building also needs Windows long-path support enabled (LongPathsEnabled = 1).

--target upload fails differently and much more confusingly under Git Bash: esptool's progress bar is drawn with box-drawing characters, stdout falls back to cp1252, and the whole upload dies on UnicodeEncodeError: 'charmap' codec can't encode characters — after sitting silent for ~6 minutes, with the traceback replacing whatever the real error was. The board is fine; nothing was written. From PowerShell the same flash takes ~18 s. If you must run it from a shell with a non-UTF-8 codepage, chcp 65001 and PYTHONIOENCODING=utf-8 clear it (observed 2026-09-21).

bash
# Install PlatformIO CLI if needed
pip install platformio

# Build
pio run -e esp32h2

# Flash
pio run -e esp32h2 --target upload

# Serial monitor
pio device monitor

Project Structure

text
src/
  config.h          — Pin definitions, constants
  color.h           — CRGB struct, HSV conversion, color math
  pixel_encode.h    — NZR-over-SPI bit encoder (hardware-free, unit-tested)
  led_driver.h/.cpp — SPI2 ring driver + LEDC CW/WW PWM
  effect_params.h   — EffectType enum, EffectParams struct
  effects.h/.cpp    — 6 effect implementations + lookup table
  scene_store.h/.cpp — NVS scene persistence
  zigbee_light.h/.cpp — Zigbee clusters for both endpoints, and the OTA client
  brightness.h      — CIE 1931 lightness curve, generated tables
  main.cpp          — Setup, loop, watchdog
test/
  test_brightness/   — Host unit tests for the perceptual curve
  test_identify/     — Host unit tests for the identify overlay
  test_light_state/  — Host unit tests for the Zigbee -> fixture translation
  test_pixel_encode/ — Host unit tests for pixel encoding + color math
  test_version/      — Host unit tests for the version block
z2m/
  lumary-brain-revA.js — Z2M external converter
  test/             — Converter tests (stubbed, `node z2m/test/converter.test.mjs`)
hardware/
  kicad/            — Board design (build_board.py generates the .kicad_pcb)
  bom.csv, calcs.md, schematic-nets.md
docs/

Tests

Hardware-independent logic (pixel encoding, colour math) has host-side unit tests:

bash
scripts\run-native-tests.bat

PlatformIO's platform = native expects a gcc toolchain; this script compiles the same tests with the MSVC Build Tools that are installed instead. pio test -e native works as-is on a machine with gcc.

The Z2M converter has its own suite, covering the effect names, their wire indices and the exposes Home Assistant discovers from them. It stubs the zigbee-herdsman-converters imports, so it needs nothing installed but Node:

bash
node z2m/test/converter.test.mjs

Status

  • Design spec
  • Implementation plan
  • Task 1: Project scaffold
  • Task 2: Color utilities
  • Task 3: LED driver
  • Task 4: Scene storage
  • Task 5: Effects engine
  • Task 6: Zigbee basics
  • Task 7: Zigbee color + effect selection
  • Task 8: Zigbee OTA
  • Task 9: BLE OTA fallback -- won't do (decided 2026-08-19); see "OTA Updates" above
  • Task 10: Integration + validation -- bench-verified end to end (docs/superpowers/plans/2026-08-18-bench-verification.md, §§1-9), and signed off from an installed ceiling location 2026-09-19: binding to the Inovelli confirmed from the loft at LQI 126-138, and a real over-the-air update completed from there (Task 6.4 in docs/superpowers/plans/2026-08-01-brain-replacement-board.md). Still open: the already-paired-fixture upgrade path -- see the note under "Two entities" above, which now needs a fixture deliberately left on pre-2.0.0 firmware to observe at all (RSSI, thermals, a real OTA from there)

License

MIT

Parts

32
QtyValueFootprintReferences
3100kResistor_SMD:R_0402_1005MetricR3, R4, R9
310uFCapacitor_SMD:C_0603_1608MetricC2, C4, C5
2100RResistor_SMD:R_0402_1005MetricR1, R2
2100nFCapacitor_SMD:C_0402_1005MetricC6, C7
25.1kResistor_SMD:R_0402_1005MetricR7, R8
2B5819WDiode_SMD:D_SOD-123D1, D2
2NMOS_60VPackage_TO_SOT_SMD:SOT-23Q1, Q2
110kResistor_SMD:R_0402_1005MetricR6
110uF/50VCapacitor_SMD:C_0805_2012MetricC1
11uFCapacitor_SMD:C_0603_1608MetricC3
11uFCapacitor_SMD:C_0402_1005MetricC8
1200RResistor_SMD:R_0402_1005MetricR5
174AHCT1G125Package_TO_SOT_SMD:SOT-23-5U3
1AO3401APackage_TO_SOT_SMD:SOT-23Q3
1BOOTButton_Switch_SMD:Panasonic_EVQPUJ_EVQPUASW1
1CN1Connector_JST:JST_ZH_S7B-ZR-SM4A-TF_1x07-1MP_P1.50mm_HorizontalJ2
1ENButton_Switch_SMD:Panasonic_EVQPUJ_EVQPUASW2
1ESP32-H2-MINI-1-N4RF_Module:ESP32-C6-MINI-1U1
1ME6211C33M5GPackage_TO_SOT_SMD:SOT-23-5U2
1PWR_INConnector_JST:JST_PH_S3B-PH-K_1x03_P2.00mm_HorizontalJ1
1SMAJ5.0ADiode_SMD:D_SMAD3
1USB-CConnector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12J3
1USBLC6-2SC6Package_TO_SOT_SMD:SOT-23-6U4

Questions

How big is the Lumary brain PCB?

It measures 62.3 × 30.3 mm and has 2 copper layers, 1.6 mm thick.

What parts does the Lumary brain use?

32 parts, built around the 74AHCT1G125SE, ESP32-H2-MINI-1-N4, ME6211C33M5G-N and USBLC6-2SC6. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.

Where can I download the Lumary brain KiCad files?

From the michaudtime/lumary-zigbee repository on GitHub, at the commit this page was made from: the board is hardware/kicad/lumary-brain.kicad_pcb. PCBKit opens the board in the browser, with nothing to install.

Can I use the Lumary brain design in my own project?

Its repository states no licence, so its author keeps all rights: ask michaudtime before reusing the design.

Similar boards

  • 2 layers13.9 × 42.8 mmLed button, the printed circuit board in 3D

    Led button

    GeniePod/genie-hardware

    GeniePod Home V1 hardware: MVP testing build, wiring, BOM, and planned interface-board/enclosure docs.

    126 parts2CERN-OHL-S-2.0
  • 4 layers41 × 33 mmDarknet smao, the printed circuit board in 3D

    Darknet smao

    dcbokonon/darknet-smao

    Darknet SMAO: Meshtastic LoRa+BLE mesh node SAO for DEF CON 34

    36 parts4
  • 2 layers85.6 × 54 mmNeuralCard, the printed circuit board in 3D

    NeuralCard

    Brillar0101/NeuralCard

    A credit-card-sized PCB that runs a neural network: ESP32-S3, 6-axis IMU, and 24 LEDs wired as the network itself. Draw a digit in the air and watch the activations light up

    72 partsCERN-OHL-P-2.0
  • 4 layers353.7 × 129.8 mmTokenBoard, the printed circuit board in 3D

    TokenBoard

    h3ll5ur7er/TokenBoard

    You wouldn't vibe code a keyboard.. or would you? uses konnect to interact with KiCad and CADence to model the case

    422 parts
  • 4 layers100 × 100 mmPhmod, the printed circuit board in 3D

    Phmod

    gallicchio/AMOD-and-RFMOD

    The tutorial's 2x12 adapter (/mudd/133electronics/icepi-zero-dac-adc/adapterboard/) with a 2 x 6 right-angle Pmod socket on a 15 mm extension of its right-hand end, so an Icepi Zero can drive AMOD-A's and RF-MOD's Pmod…

    234 parts
  • 2 layers100 × 62 mmAnalog board, the printed circuit board in 3D

    Analog board

    etiennechlt/electronic-chess

    Générée par tools/boardgen (module power). 100 x 60 mm, 2 couches, plan de masse en face arrière. Elle gère trois cellules plates 1S 5 Ah.

    138 parts
  • 4 layers56 × 28 mmBx tacho, the printed circuit board in 3D

    Bx tacho

    dazchi/bx-tacho

    Open DIY Beyblade X launcher RPM meter: ESP32-C3 with IR sensors and an OLED, Bluetooth (Battle Pass protocol), and a browser app for shot data and firmware updates. KiCad hardware, ESP-IDF firmware, 3D-printed case.

    51 parts3GPL-3.0
  • 2 layers86 × 60 mmPmbh, the printed circuit board in 3D

    Pmbh

    3351666087/solar-vest

    Self-powered solar-harvesting wearable ECG vest: PCBs, 3D-printed enclosures, vest patterns, simulation and an interactive 3D explorer

    100 parts