// Native HomeKit garage door controller // Seeed XIAO ESP32-C3 + Challenger AC9500 (Allstar AC9000 series) // // Pairs directly with the Apple Home app. No hub, no cloud, no server. // "Hey Siri, open the garage" works on the local network. // // WIRING // Relay COM + NO -> operator terminals 0 and 1, in PARALLEL with the // existing wall push button (which keeps working). // D2 / GPIO4 -> relay module trigger, PLUS a 10k pulldown to GND // D3 / GPIO5 -> reed switch -> GND (magnet aligned = door CLOSED) // Leave terminals 4/5 (Safe Finish Photosystem) and 1/2/3 alone. // // OPENER QUIRKS THIS HANDLES // * Alternating action: mid-travel a button press means STOP, not // reverse. So we only ever pulse from a settled end state and // refuse commands while the door is moving. // * "Constant Contact To Close": if the photo eyes are misaligned or // faulty, a momentary pulse will NOT close the door -- the opener // demands the button be held for the entire cycle. We deliberately // do not automate that override (it exists so a human watching the // door can force it). Instead we detect the failure and raise // HomeKit's ObstructionDetected, which is exactly what it's for. // BUILD NOTE // Requires the "Minimal SPIFFS (1.9MB APP with OTA)" partition scheme -- // HomeSpan + WiFi + OTA overflows the stock 1.2MB app partition. // Arduino IDE: Tools > Partition Scheme > Minimal SPIFFS // arduino-cli: --fqbn esp32:esp32:XIAO_ESP32C3:PartitionScheme=min_spiffs // Or just use ../flash.sh, which sets it for you. #include "HomeSpan.h" // ---------- pins ---------- constexpr uint8_t RELAY_PIN = 4; // D2 -- avoid strapping pins 2, 8, 9 constexpr uint8_t REED_PIN = 5; // D3 // ---------- timing ---------- constexpr uint32_t PULSE_MS = 500; // button press length constexpr uint32_t DEBOUNCE_MS = 200; // doors rattle constexpr uint32_t LOCKOUT_MS = 2000; // min gap between presses // The AC9000 control board run timer is 17s stock, or 29s if the jumper // was cut (doors with >9 ft of travel). Set a few seconds above yours. constexpr uint32_t TRAVEL_MS = 20000; // use 32000 if the jumper is cut // ---------- HomeKit CurrentDoorState values ---------- constexpr uint8_t DOOR_OPEN = 0; constexpr uint8_t DOOR_CLOSED = 1; constexpr uint8_t DOOR_OPENING = 2; constexpr uint8_t DOOR_CLOSING = 3; // ---------- HomeKit TargetDoorState values ---------- constexpr uint8_t TARGET_OPEN = 0; constexpr uint8_t TARGET_CLOSED = 1; struct GarageDoor : Service::GarageDoorOpener { Characteristic::CurrentDoorState current{DOOR_CLOSED}; Characteristic::TargetDoorState target{TARGET_CLOSED}; Characteristic::ObstructionDetected obstruction{false}; // reed debounce bool reedRaw = true; bool doorClosed = true; uint32_t reedChanged = 0; // relay pulse bool relayOn = false; uint32_t pulseStart = 0; uint32_t lastPulse = 0; // travel window bool traveling = false; bool closingNow = false; uint32_t travelStart = 0; GarageDoor() : Service::GarageDoorOpener() { pinMode(RELAY_PIN, OUTPUT); digitalWrite(RELAY_PIN, LOW); // assert safe state immediately pinMode(REED_PIN, INPUT_PULLUP); reedRaw = doorClosed = (digitalRead(REED_PIN) == LOW); current.setVal(doorClosed ? DOOR_CLOSED : DOOR_OPEN); target.setVal(doorClosed ? TARGET_CLOSED : TARGET_OPEN); } void pressButton() { digitalWrite(RELAY_PIN, HIGH); relayOn = true; pulseStart = millis(); lastPulse = pulseStart; } // Called when HomeKit (or Siri) writes TargetDoorState. boolean update() override { uint8_t want = target.getNewVal(); if (traveling) { LOG1("Garage: refusing command, door is mid-travel\n"); return false; // surfaces as an error in Home } if (millis() - lastPulse < LOCKOUT_MS) { LOG1("Garage: refusing command, lockout window\n"); return false; } // Already where it wants to be -- accept, do nothing. if (want == TARGET_CLOSED && doorClosed) return true; if (want == TARGET_OPEN && !doorClosed) return true; pressButton(); traveling = true; closingNow = (want == TARGET_CLOSED); travelStart = millis(); if (!closingNow) obstruction.setVal(false); // opening clears the fault LOG1(closingNow ? "Garage: closing\n" : "Garage: opening\n"); return true; } void loop() override { uint32_t now = millis(); // --- end the relay pulse --- if (relayOn && now - pulseStart >= PULSE_MS) { digitalWrite(RELAY_PIN, LOW); relayOn = false; } // --- debounce the reed switch --- bool raw = (digitalRead(REED_PIN) == LOW); // LOW = magnet = closed if (raw != reedRaw) { reedRaw = raw; reedChanged = now; } if (now - reedChanged >= DEBOUNCE_MS) doorClosed = reedRaw; // --- travel window expiring --- if (traveling && now - travelStart >= TRAVEL_MS) { traveling = false; if (closingNow) { // Didn't reach closed? The pulse was ignored or the door reversed. // On this opener that means the Safe Finish photo eyes are blocked, // misaligned, or failed. Report it -- do not retry, retrying can't // help when the opener wants constant contact. bool failed = !doorClosed; obstruction.setVal(failed); if (failed) LOG0("Garage: CLOSE FAILED -- check the photo eyes\n"); } } // --- report state, including moves made from the wall button --- uint8_t want = traveling ? (closingNow ? DOOR_CLOSING : DOOR_OPENING) : (doorClosed ? DOOR_CLOSED : DOOR_OPEN); if (current.getVal() != want) current.setVal(want); if (!traveling) { uint8_t t = doorClosed ? TARGET_CLOSED : TARGET_OPEN; if (target.getVal() != t) target.setVal(t); } } }; void setup() { Serial.begin(115200); homeSpan.setPairingCode("46637726"); // CHANGE THIS before pairing homeSpan.enableOTA(); homeSpan.begin(Category::GarageDoorOpeners, "Garage Door"); new SpanAccessory(); new Service::AccessoryInformation(); new Characteristic::Identify(); new Characteristic::Name("Garage Door"); new Characteristic::Manufacturer("DIY"); new Characteristic::Model("XIAO ESP32-C3"); new GarageDoor(); } void loop() { homeSpan.poll(); }