Turning a Ritto 7630 TwinBus apartment intercom into a fully integrated Home Assistant device with a Seeed XIAO ESP32-C6 and ESPHome.
I rent a flat with one of these old TwinBus intercoms on the wall. It does exactly one thing: it chimes, and if I happen to be standing next to it I can press a button to buzz the street door open. I wanted more than that. I wanted my phone to tell me who is at the door, I wanted to buzz people in from anywhere, I wanted parcels to end up in the hallway when I am not home, and I wanted to let myself in without digging for keys when I come back with my hands full.
There are a handful of existing projects that smarten a TwinBus (Shelly, Wemos, powering the board from the bus, see sources), but each made a trade-off I did not want: not really telling the two bells apart, or hanging off the unreliable bus voltage for power. So I put an oscilloscope on the thing, worked out how it signals, and built my own. The code is the easy part. The interesting work, and most of this write-up, is the reverse engineering.
- Know the difference between the apartment bell (someone at my flat door inside the building) and the street bell (someone at the main entrance).
- Send a phone notification when either one rings.
- Open the street door from my phone, from anywhere.
- A parcel mode: when I am out and a delivery rings, open the door automatically so the courier can leave the box in the hallway.
- A one-tap "let me in" for myself and a few trusted people.
The Ritto 7630 is the indoor handset of a TwinBus system. Everything (both door stations and all the flats) shares a single two-wire bus that carries power and signalling together. The board chimes with two different tones, one for the street door and one for the apartment door, and it has a button that briefly energises the door opener at the street station.
To make this smart I needed three things from the board: a clean signal for "the street bell rang", a clean signal for "the apartment bell rang", and a way to trigger the door opener myself. None of that is exposed as a tidy interface, so I had to find it on the board.
I tapped the candidate signal lines on the back of the board against ground and rang each bell while watching them on a scope.
Two lines turned out to matter. On the scope, channel 1 (blue) is the chime line and channel 2 (yellow) is the apartment line.
Street door. The chime line normally sits at ground and jumps to a clean digital HIGH of about 5V for a fixed length of time (around 500 ms), then drops back. The apartment line does not move at all.
Apartment door, short press. Now the apartment line, which normally idles HIGH at about 5V, gets pulled to ground for exactly as long as the button is held. The button behind the wall is just a mechanical contact that shorts this line to ground while pressed. The chime line fires its 500 ms pulse too, but not at the same instant.
Apartment door, long press. I measured a short and a long press on purpose, to find out what the chime pulse is actually tied to. Both shots are triggered on the chime pulse (channel 1), so it sits at the centre of the screen in each. Notice that the apartment line's falling edge lands at the same spot in both, while its rising edge (the moment I let go) moves with how long I held the button.
So the chime always fires a fixed delay after the press begins (a little over three divisions on the 500 ms/div grid, so roughly 1.6 s), and the press duration does not matter at all. The trigger for the whole event is the falling edge of the apartment line. This is why the firmware only reacts to that falling edge, and why a fixed blocking window is enough to swallow the chime echo that follows.
Here is the catch that shapes the whole design: the chime line pulses for both bells, so on its own it cannot tell them apart. The discriminator is the apartment line. If it drops to ground, the ring was the apartment bell; if it stays HIGH, the ring was the street door.
This matches what others mapped on the same board (see quhfan.de and the deh0511 TwinBus pinout). The relevant board terminals are:
| # | Terminal | Behaviour |
|---|---|---|
| 1 | GND | Ground |
| 2 | KLINGEL (chime) | Goes to ~5V on any ring |
| 3 | +24V | Bus voltage |
| 4 | ETAGE (apartment) | Idles at ~5V, drops to ground on an apartment ring |
| 5 | Door opener | Buzzes the street door when pulled to ground |
| 6 | GND | Ground |
The scope also answers the practical question of how to interface safely. The signalling happens at about 5V, while the bus as a whole runs at +24V. I read the ~5V level directly off the traces (the captures top out around 4.8V), and that is the number I size the input resistors against further down.
With the signals understood, the logic is small:
- ETAGE (terminal 4) → GPIO D9. When it drops to ground, an apartment ring is happening.
- KLINGEL (terminal 2) → GPIO D10. Pulses on every ring.
- Because KLINGEL also pulses during an apartment ring, the firmware blocks the KLINGEL line for a few seconds whenever ETAGE fires. That leaves a bare KLINGEL pulse meaning "street door" and an ETAGE drop meaning "apartment door".
- Door opener (terminal 5) → GPIO D8. A short pulse pulls it to ground and buzzes the street door.
That blocking flag is the heart of esphome/ritto-intercom.yaml.
Home Assistant only ever sees two clean binary sensors and an "Open Door" button;
the raw bus inputs and the relay stay internal to the node.
I did not want to wire the ESP32 directly to a 24V bus, so every line between the intercom and the XIAO goes through an optocoupler for galvanic isolation: two on the inputs (KLINGEL, ETAGE) and one on the output (door opener). The XIAO is powered over USB-C, which sidesteps the unreliable bus voltage entirely.
| Ritto terminal | Role | Through | XIAO pin |
|---|---|---|---|
| 2 KLINGEL | chime, any ring | input optocoupler | D10 |
| 4 ETAGE | apartment line | input optocoupler | D9 |
| 5 Door opener | buzz street door | output optocoupler | D8 |
| 1 / 6 GND | ground | — | GND |
On the build photo the screw terminals are labelled (left to right) TÜR (door opener), KLINGEL, ETAGE, and Ritto GND. The XIAO is wired to D8, D9, D10 and GND.
Each optocoupler LED needs a series resistor. For a common PC817 (LED forward voltage ~1.2V, a comfortable forward current of ~5mA):
- Inputs (KLINGEL, ETAGE): driven by the ~5V signal lines, so R = (5 − 1.2) / 0.005 ≈ 760Ω, a standard 680Ω works well.
- Output (door opener): driven by the XIAO's 3.3V GPIO, so R = (3.3 − 1.2) / 0.0095 ≈ 220Ω, which is the value in the schematic.
| Part | Notes |
|---|---|
| Seeed XIAO ESP32-C6 | The controller |
| 3 × PC817 optocoupler (or similar) | 2 inputs, 1 output |
| 2 × ~680Ω resistor | Input optocoupler LEDs |
| 1 × 220Ω resistor | Output optocoupler LED |
| Perfboard | Any small prototype board |
| 4-position screw terminal | Bus connection |
| USB-C cable + 5V supply | Power |
| Hookup wire |
I used a piece of perfboard because that is what I had. A proper PCB or even a small breadboard would do the same job.
Note
The hand-drawn schematic below is an early draft and uses different GPIOs than the version I actually built. The authoritative pin assignment is the one in the table above and in the ESPHome config: D8 door opener, D9 apartment, D10 chime.
Note
Pin polarity matters in the firmware: the apartment input uses an internal
pullup, and the chime input is configured pullup + inverted because the
optocoupler pulls the pin to ground on a ring. The delayed_on filters debounce
the bus. Do not change these without rechecking against the scope captures above.
The full config is esphome/ritto-intercom.yaml.
Wi-Fi credentials live outside it, so copy the example secrets file first:
cp esphome/secrets.yaml.example esphome/secrets.yaml
# edit esphome/secrets.yaml
esphome run esphome/ritto-intercom.yaml # build, flash over USB (OTA afterwards)
esphome logs esphome/ritto-intercom.yaml # stream logsThe config switches the XIAO to its external antenna on boot; drop that block if you use the internal one.
Once the node is adopted, Home Assistant has two binary sensors
(...doorbell_apartment, ...doorbell_main) and a ...open_door button. The
example automations are in home-assistant/, translated to
English with secrets replaced by placeholders. This is where the project actually
earns its keep.
Knowing who rang. Every ring sends a Telegram message. The street-door message carries an inline "open door" button, so I can buzz someone in straight from the notification without opening any app. A small retry loop makes it robust against Telegram timeouts, which were the one annoying source of missed notifications early on.
Parcel mode. This is the feature I use most. When I am not home and expecting
a delivery, I send the bot /postmode_on. The next time the street bell rings,
the door opens automatically after three seconds and the courier leaves the parcel
in the hallway. I never have to be there or even react. /postmode_off puts it
back to normal, and /postmode reports the current state. It runs locally on
Home Assistant, so it does not depend on the Telegram round-trip.
Letting myself in. Coming home with full hands and fishing for keys is exactly the kind of small daily annoyance worth automating away. Each trusted person gets their own unguessable webhook URL that buzzes the door. On my phone it is a single tap: an iOS Shortcut on the Home Screen (or an NFC tag by the door), an HTTP request automation on Android. Walk up, tap, door opens.
- In
home-assistant/automations/webhook-access.yaml, give each person a long randomwebhook_id. - The webhook URL is
https://<your-ha>/api/webhook/<that-id>(POST). - iOS: Shortcuts → new shortcut → "Get Contents of URL" pointing at that URL, method POST. Add it to the Home Screen, or trigger it from an NFC tag.
- Android: any HTTP-request automation app (HTTP Shortcuts, Tasker, etc.) calling the same URL.
Keep it behind HTTPS and treat the webhook ids like passwords for your front door, because that is what they are.
- deh0511.de TwinBus — bus and terminal pinout
- quhfan.de: Ritto TwinBus 7630 in Home Assistant — the numbered board legend
- nicht-trivial.de: Ritto zu MQTT
- XIAO ESP32-C6 antenna switching








