Skip to the content
PCBKitNew board
2 layers30 × 58.5 mmLoragate tht, the printed circuit board in 3D

Loragate tht

andrewgho/loragate

ESP32 + LoRa radio powered reed switch door sensor

Size
30 × 58.5 mm
Copper layers
2
Thickness
1.6 mm
Parts
10
Nets
20
Pads
46
Tracks, vias
60, 0
Pours
1
Routed
Yes
KiCad
KiCad 10.0 (file 20260206)
Created
20 Oct 2026
Updated
5 Nov 2026

Overview

Loragate tht is an open-source printed circuit board by andrewgho, from the andrewgho/loragate repository on GitHub. It is a 2-layer board of 30 × 58.5 mm with 10 parts on 20 nets, routed with 60 tracks and 0 vias.

It is built around the MCP1700-3302E/TO and MF-R050. Its parts include 3 ICs, 3 connectors, 2 capacitors, 1 resistor and 1 fuse. It belongs with the radio and wireless and sensors and measurement boards and the ESP32 and RISC-V boards of this library.

Main components

2

README

README.md

loragate

A battery-powered door/gate sensor that reports open, close, and "still open" events over LoRa to an always-on receiver, which forwards them to a WiFi-connected monitoring server.

Requirements

  • Multi-year battery life on primary AA cells, outdoors, unattended — no solar, no mains run to the gate.
  • Never silently miss a door state change, even across a reset.
  • Usable range through walls/trees between the gate and the house (no WiFi or cellular at the gate).
  • No recurring cost (no cellular plan, no cloud radio service).
  • Some visibility into the transmitter's own health (is it still alive, did it reset unexpectedly) without needing a visit to check.

Implementation pieces

  • tx/ — the outdoor transmitter: XIAO ESP32-C6 + Wio-SX1262, reed switch, 3x AA lithium cells. Deep-sleeps between events.
  • rx/ — the indoor receiver: same radio hardware, USB-powered, always on, WiFi-connected. Acks tx, forwards events to a monitoring server over HTTP, and lights a local held-open beacon.
  • test/ — host-side tests (no ESP32 toolchain needed) for the parts of tx/rx written to be testable without hardware: the wire format, tx's wake-decision logic, and rx's retry-dedup logic.

Why these choices

LoRa, not WiFi or cellular, for the transmitter. WiFi's join/auth cost alone is worse than LoRa's entire transmit burst, and there's no cellular plan to pay for recurring. LoRa trades bandwidth (a few bytes a message) for exactly what this needs: long range at near-zero duty cycle, license-free. The receiver is WiFi-connected, but it's mains-powered — it absorbs that cost so the battery-powered side doesn't have to.

XIAO ESP32-C6, deep-sleep by default, event-driven wake. Polling the reed switch on a timer would mean staying awake far more than the door actually changes state. Instead, tx deep-sleeps and wakes only on a reed-switch edge or a backstop RTC timer (see "Software" below) — the C6's RTC domain is what makes both of those possible as a true low-power wake source, and RTC_NOINIT_ATTR memory is what lets a few bytes of state (sequence counter, last-reported door state) survive both sleep and an unexpected restart without re-deriving them from scratch every wake.

Primary AA lithium (Energizer L91), not rechargeable Li-ion, through an LDO. A sealed outdoor enclosure in direct sun runs hotter than ambient, and that's exactly where Li-ion/LiPo capacity fade and pouch-cell failure modes get worse — the alarm industry ships door/window sensors on primary lithium for the same reason. The XIAO's 3V3 pin is wired straight to the chip's core supply with no protection, and a fresh pack can sit close to the chip's own voltage ceiling with no margin — an LDO (plus a PTC fuse ahead of it) fixes both the regulation and the short-circuit exposure in one part. See WIRING.md's Transmitter "Power" for the specific part and the alternative considered.

Simple ack/retry over the radio link, not a heavier protocol. Each event is one small packet (type + sequence number + retry count); tx retries with backoff until rx acks it or it gives up. That's enough reliability for "a handful of events a day, on a link that's usually clean," without the complexity of a synchronized/meshed protocol this traffic pattern doesn't need.

The wake/sleep decision logic is pure C++, separate from the Arduino/radio code. tx/door_wake_logic.h decides what a wake means and should do with no Arduino, radio, or Serial dependency; tx/tx.ino is just the I/O shell that reads the pin and carries out the decision. That split is what lets test/test_tx_wake_logic.cpp exercise the actual decision rules — including regression cases for real bugs caught along the way (see "History" below) — with a plain C++ compiler, no hardware required.

Power budget

Deep-sleep current, measured live on the built transmitter with a Nordic PPK2 (see "History" below for how the first measurement — 1.5mA, not microamps — got root-caused):

SourceCurrent
Deep sleep, door closed21.9µA
Deep sleep, door open18.5µA
Active transmit (~120ms burst, +22dBm)~125mA

Budgeting conservatively for ~54 radio events/day (20 door transitions + an hourly heartbeat floor + occasional held-open checks — see "Software" below) against ~2,100mAh realistically extractable from a 3x AA L91 pack:

text
sleep:  0.0219mA * 24h                    ≈ 0.526 mAh/day
active: 54 * 0.120s * 125mA / 3600        ≈ 0.225 mAh/day
total                                     ≈ 0.751 mAh/day
2,100 mAh / (0.751 mAh/day * 365)         ≈ 7.7 years

Comfortably past the "2+ years would be satisfying" bar, with enough margin to absorb temperature, pack-tolerance, and retry-rate uncertainty the measurement doesn't cover. Not modeled: a worst case of constant ack-retries would cost more than the single-burst-per-event assumption above — live field logs show this is rare in practice, but it's not measured.

History

  • The project's first draft (an AI-authored plan, not hardware-checked) got real things wrong: a reed-switch pin that collided with the radio's own NSS pin, and a direct battery-to-3V3 connection with no margin against the chip's voltage ceiling. Both forced real design changes — see WIRING.md's Transmitter pin table and LDO section.
  • The transmit-current figure was off by more than 3x for a while (~35mA assumed vs. ~120-125mA actual) until it was checked against the SX1262 and Wio-SX1262 module datasheets directly.
  • Deep-sleep current measured ~1.5mA on the first PPK2 pass — ~40x the budget. Root cause took two parts: the SX1262 wakes itself from SLEEP on an NSS falling edge, and the ESP32-C6 doesn't hold a GPIO's level across sleep unless told to (fixed with gpio_hold_en()); and the Wio-SX1262 HAT's own onboard button shares the reed switch's D0 net with a pull-up that drew current whenever the door was closed (confirmed against Seeed's schematic, fixed by bypassing D0 around the HAT in hardware — see WIRING.md). Final measured number is the 21.9µA/18.5µA above.
  • A while (!Serial) wait-for-USB-monitor loop in tx's setup() burned up to 3 seconds of fully-awake CPU time on every single wake in the field — a real battery-life cost, not just a latency annoyance — because deep sleep drops the USB peripheral and nothing was ever there to satisfy the wait. Fixed by gating it behind -DDEBUG.

Hardware

Both nodes use the same radio hardware — a Seeed XIAO ESP32-C6 paired with a Seeed Wio-SX1262 (the "for XIAO ESP32S3" through-hole-header variant; Seeed doesn't sell this exact pairing, so the pinout is confirmed against the physical boards, not a datasheet — see WIRING.md's "Shared radio stack"), each with the small ribbon antenna that ships with the Wio-SX1262. What differs is what's attached, how each is powered, and what the modules mount on.

This section covers what the pieces are and why; WIRING.md has the full pin tables, part numbers/links, and physical build/wiring instructions, in matching Transmitter/Receiver sections — that's the doc to open to actually order parts or wire up a unit.

Transmitter

Reed switch on D0 (the only free deep-sleep-wake-capable pin), 3x AA lithium pack through an LDO and a PTC fuse, on a custom KiCad PCB (kicad/) in an IP65 enclosure with the antenna inside. No USB power in the field. The PCB doesn't stack the modules as a HAT: it seats them side by side, with the Wio flipped on its back for easier routing. That lets it leave the Wio's D0 unconnected, which isolates the reed switch from the HAT's onboard button without modifying either module. A UART header on D6/D7 takes an FTDI adapter for production-build serial monitoring. A 3D-printed mount (mount/) holds the PCB and battery holder in the enclosure. (The original Perma-Proto protoboard build is still documented in WIRING.md.)

Receiver

USB-powered, indoors, no battery or enclosure concerns — it just needs to stay on and stay on WiFi. The usual XIAO + Wio HAT stack plugs into a 1"x1" protoboard wired as a dual 7-pin socket, which also carries the one peripheral: a small 5V LED rotating beacon, lit while the gate is held open, switched from D0 by a low-side logic-level MOSFET.

History

  • The Wio-SX1262's pinout for this XIAO had to be read off the boards' silkscreen — an earlier schematic-derived guess was off by one header position and failed radio init outright (RADIOLIB_ERR_CHIP_NOT_FOUND).
  • The transmitter's first LDO pick (TI TPS7A0233, true nanopower) had a dead bench unit (0V output, bad part or solder joint, not a design issue); the MCP1700 — higher quiescent current, but simpler to build and confirmed working — is what's actually installed. TPS7A0233 remains a documented alternative if a working unit gets built later. See WIRING.md's "Choosing an LDO".
  • The Wio-SX1262 HAT's own onboard button turned out to share the reed switch's D0 net (see "Power budget" → "History" above) — a hardware quirk of the stacked-header build, not something visible from either board's datasheet alone. The protoboard build fixed it by snipping D0 out of the stack; the PCB avoids it by design.
  • The battery disconnect moved from JST-PH 2.0mm to Molex KK-254-style (2.54mm, generic KF2510 kit parts): JST-PH crimp pins aren't practical to crimp at home, and KK-254-style ones are.
  • Long-range SMA antennas (with U.FL-to-bulkhead pigtails) were bought for both nodes. The Wio-SX1262's stock ribbon antenna turned out to give enough range, so both nodes use that: it fits inside the enclosure and takes up little room.
  • The receiver's beacon was first wired to D6, reasoning that rx's Serial runs over native USB so UART0's pins were free. The gate sat at ~3V whatever the firmware wrote. Root cause, confirmed against the installed core's sdkconfig rather than a web search: the prebuilt ESP-IDF has its console on UART0 regardless of CDCOnBoot (an SDK configuration choice, not a silicon limit, as an earlier draft wrongly claimed). Moved to D0. D7 is ruled out for the same reason.
  • After the move to D0, the gate read a steady ~1.6V and the beacon stayed off. That's almost exactly the divider voltage for an undriven D0 (the HAT's 10kΩ pull-up against the beacon's 220Ω+10kΩ pulldown). The cause was the macOS flashing box's stale checkout, still flashing the D6 build. A pull and reflash fixed it, with no hardware change. Check git pull before chasing an electrical-looking symptom.
  • The beacon was first planned on a separate standalone protoboard, wired to the stack through a pair of 1x3 headers. That layout's first draft landed the GND→Source jumper on the D0 island instead of a GND one, which would have tied D0 straight to the MOSFET source. It was caught in review, before soldering, by tracing each hole against the header assignment rather than trusting the layout's prose labels. The as-built board instead puts the circuit on the protoboard that sockets the stack, with a 2-pin output header.
  • The beacon circuit's flyback diode was dropped after a PPK2 measurement showed the beacon is LED-only, not motor-driven.

Software

Transmitter (tx/)

Deep sleeps by default. Each wake runs setup() once (there's no persistent loop()), decides what — if anything — to send, and goes back to sleep from inside that decision:

  • Wakes on a reed-switch edge, or an RTC timer backstop that's always armed (a short one while an open episode hasn't yet been re-verified, a long hourly one otherwise).
  • Every wake diffs the real pin state against a persisted "last reported" baseline — whether something changed is never inferred from why the chip woke up, only from that diff. A real edge that settles back to the same state (reed-switch bounce/chatter outdoors) is told apart from a genuinely elapsed timer and sent nothing.
  • A door transition is reported immediately; a door left open past a threshold gets one follow-up "still open" check (not a repeating one); anything else falls back to an hourly heartbeat floor so silence never goes on indefinitely.
  • The handful of values that need to survive both sleep and a restart (sequence counter, last-reported door state, a pending reset-diagnostic flag) live in RTC_NOINIT_ATTR memory, validated by a checksum (tx/rtc_checksum.h) since that memory section isn't zeroed by the bootloader the way RTC_DATA_ATTR would be.
  • An abnormal boot/reset reason (anything other than a routine deep-sleep wake or the transmitter's own restart) gets reported as a diagnostic packet, detected before the radio is even brought up so it isn't lost if radio.begin() happens to fail on that particular wake.
  • The wake-decision rules themselves live in tx/door_wake_logic.h — see "Why these choices" above for why that's a separate, pure file.

Receiver (rx/)

Always on, always listening:

  • Acks every recognized packet (so tx stops retrying) and dedups by sequence number before acting on it, since an ack-loss retry resends the same event.
  • Forwards door open/close/heartbeat/held-open/diagnostic events to a monitoring server over HTTP (URLs and WiFi credentials in rx/local.h — see "Build, test, and flash").
  • Joins WiFi under RX_HOSTNAME (DHCP hostname, so the router shows it and serves local DNS for it) and advertises it over mDNS as <name>.local.
  • Drives the held-open beacon through beacon_should_be_on(). For now it's lit whenever the door's open (testing phase); the intended final behavior, lit only after a held-open alert fires, is a one-line change there.
  • Runs its own lightweight checks independent of what the transmitter decides to send: a faster local "door open past 45s" alert, a "haven't heard from the transmitter in a while" staleness alert, and proactive WiFi reconnect if the connection drops.

Wire protocol

One packet type covers acks (PKT_ACK); the rest cover transmitter events (PKT_HEARTBEAT, PKT_DOOR_OPEN, PKT_DOOR_CLOSE, PKT_DOOR_HELD_OPEN, PKT_DIAGNOSTIC) — see lora_packet.h (kept byte-identical between tx/ and rx/, checked by make test) for the exact struct. tx sends, waits for a matching ack, and retries with backoff (capped attempts) before giving up on that one event.

Build, test, and flash

Toolchain: arduino-cli with the ESP32 board package, plus the RadioLib library:

sh
arduino-cli lib install RadioLib

From the repo root:

  • make compile — compiles tx/ and rx/ against the installed ESP32 core + RadioLib. No upload, no hardware needed.
  • make test — host-side tests (test/), no ESP32 toolchain needed at all, just a C++ compiler. Covers the wire format and the pure logic described above; does not reach actual hardware behavior (real deep sleep, a real reed-switch edge, radio range, WiFi/HTTP) — only flashing a real board confirms those.
  • make flash-tx / make flash-rx — flash tx/rx.

Each sketch directory also has its own Makefile (make flash, make debug, make monitor, make clean). make debug substitutes a delay()+restart loop for deep sleep so USB/serial stays up for monitoring, and builds with -DDEBUG (enables verbose logging and, on tx, the wait-for-monitor loop that production builds skip — see "History" below).

rx/ needs rx/local.h (gitignored) for WiFi credentials and the HTTP URL templates it forwards events to — copy rx/local.h.example and fill it in. tx/ needs no local.h (no WiFi).

History

  • The transmitter's wakeup source was originally planned as esp_sleep_enable_ext0_wakeup(); the ESP32-C6 doesn't implement ext0 at all. esp_sleep_enable_ext1_wakeup() (a GPIO bitmask, not a pin number) replaced it.
  • A live brownout-looking symptom (rapid door toggling occasionally dropped an event and reset the sequence counter to 0) turned out to be a real persistence bug, not a hardware brownout: RTC_DATA_ATTR is only documented to survive deep sleep, not ESP.restart() (the rapid-slam/bounce guard's own restart) — switching to RTC_NOINIT_ATTR plus a checksum fixed it.
  • A reed-switch edge that bounces back to the same debounced state (wind/vibration on an outdoor gate) was originally indistinguishable from a genuinely elapsed timer, so it could burn an unneeded heartbeat or consume the one-shot held-open check's budget early. Fixed by checking the actual wakeup cause, but only in the already-no-transition branches.
  • If radio.begin() ever failed on the very first wake after a true power-on, a later successful wake could silently adopt whatever the door happened to be doing then as the starting baseline — losing any real transition that happened in between with no event ever sent. Fixed by establishing the baseline from the pin alone, before the radio is even touched.
  • The held-open check originally re-armed every interval for as long as the door stayed open (~280 wakes/day for a day-long open door); made one-shot per open episode instead.
  • An ack-loss retry (receiver got and acked the original fine, but the ack itself got lost) was double-notifying the monitoring server for the same event; fixed by dedup-by-sequence-number on the receiver before acting on a packet, while still acking unconditionally.
  • The receiver's "transmitter's gone quiet" staleness alert originally cleared its one-shot latch on any radio reception, including a CRC failure — caught live when CRC failures logged while the transmitter was confirmed powered off overnight suppressed the alert that should have kept firing. Fixed by only clearing the latch on a reception that actually decodes as a known packet type (the silence clock still resets on any reception, since that part was never wrong).
  • NTP sync and a reported localtime= field were added, then removed: the actual timing gap they were meant to close (radio-level latency) is usually under a second and gets swamped by the monitoring channel's own latency anyway — not worth the added wire-format and firmware complexity.

Parts

10
QtyValueFootprintReferences
21uFCapacitor_THT:C_Disc_D5.0mm_W2.5mm_P5.00mmC1, C2
11MResistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P10.16mm_HorizontalR1
1BATTConnector_Molex:Molex_KK-254_AE-6410-02A_1x02_P2.54mm_VerticalJ1
1MCP1700-3302E/TOPackage_TO_SOT_THT:TO-92_Inline_WideU1
1MF-R050Capacitor_THT:C_Disc_D8.0mm_W2.5mm_P5.00mmF1
1SWITCHConnector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_VerticalJ2
1UARTConnector_PinHeader_2.54mm:PinHeader_1x03_P2.54mm_VerticalJ3
1XIAO_ESP32C6LoRa_Gate_Local:XIAO_Module_Socket_2x7_P2.54mm_W15.24mmU2
1XIAO_WioSX1262LoRa_Gate_Local:XIAO_Module_Pin_Flipped_2x7_P2.54mm_W15.24mmU3

Questions

How big is the Loragate tht PCB?

It measures 30 × 58.5 mm and has 2 copper layers, 1.6 mm thick.

What parts does the Loragate tht use?

10 parts, built around the MCP1700-3302E/TO and MF-R050. 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 Loragate tht KiCad files?

From the andrewgho/loragate repository on GitHub, at the commit this page was made from: the board is kicad/loragate_tht/loragate_tht.kicad_pcb and its schematic kicad/loragate_tht/loragate_tht.kicad_sch. PCBKit opens the board in the browser, with nothing to install.

Can I use the Loragate tht design in my own project?

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

Similar boards

  • 2 layers50 × 90 mm4dapter, the printed circuit board in 3D

    4dapter

    timville85/4dapter

    This project combines the NES, SNES, N64 and Genesis DaemonBite Retro Controllers projects together with a custom PCB to support 4 different controllers with a single socketed Arduino Pro Micro.

    16 parts82GPL-3.0
  • 4 layers290 × 215 mmPlacard, the printed circuit board in 3D

    Placard

    tocksin/placard

    PLA computer

    187 parts2AGPL-3.0
  • 2 layers50.8 × 22.9 mmFeather fc, the printed circuit board in 3D

    Feather fc

    MarkGhebrial/feather-fc

    The PCB for my flight controller project

    22 parts1
  • 2 layers90 × 90 mmP 4.1 2026 Jeff M, the printed circuit board in 3D

    P 4.1 2026 Jeff M

    Jeffrey-McCormick/Project-Heartbeat

    End-to-end embedded system project to accurately measure the heart rate of a person

    38 partsMIT
  • 2 layers36.6 × 41.1 mmProject 2-K Auyeung, the printed circuit board in 3D

    Project 2-K Auyeung

    kyauyeung/PCB-Designs

    Repository for schematic and PCB designs

    11 parts
  • 2 layers38.5 × 33.5 mmC3 GestureDriver, the printed circuit board in 3D

    C3 GestureDriver

    MengqiS21/TECHIN514_FinalProject

    A wearable sensing tag for pets and a tabletop display that visualizes daily activity and proximity-based companionship.

    16 parts
  • 2 layers30 × 75 mmC3 GestureDriver Screen, the printed circuit board in 3D

    C3 GestureDriver Screen

    MengqiS21/TECHIN515_final_project-individual-

    Vehicle engine faults such as air leaks, ignition misfires, and abnormal idle behavior often produce characteristic acoustic signatures that trained mechanics can identify by ear.

    10 parts
  • 2 layers100.1 × 78.2 mmENMT LFR, the printed circuit board in 3D

    ENMT LFR

    r3dpath/ENMT-LFR

    56 parts