Initial commit: WebRTC Doorbell Card v0.4.0

This commit is contained in:
2026-05-05 17:49:55 +00:00
commit a2a4ef2a14
5 changed files with 347 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.DS_Store
*.log
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Miguel Palhas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+77
View File
@@ -0,0 +1,77 @@
# WebRTC Doorbell Card
A Home Assistant Lovelace card that wraps [AlexxIT/WebRTC](https://github.com/AlexxIT/WebRTC)'s `webrtc-camera` and adds a proper doorbell-style call UX:
- **Answer / Hangup** buttons that toggle *both* directions of audio at once (incoming via `video.muted`, outgoing via the mic track's `enabled` flag) — unlike the bare WebRTC card whose mute button only affects the incoming stream.
- **Configurable unlock button** that fires any HA action (`call-service` / `perform-action`).
- **Haptic + visual feedback** on tap — vibration where supported, scale + flash + checkmark on the unlock button.
- **Mic captured up-front** but kept disabled until you press Answer, so there's no permission prompt mid-call.
## Requirements
- [AlexxIT/WebRTC](https://github.com/AlexxIT/WebRTC) integration installed (this card delegates the actual WebRTC streaming to its `webrtc-camera` card).
- A camera entity that supports two-way audio via go2rtc (Frigate cameras work, as do Dahua VTOs and many others — see the AlexxIT docs).
## Installation
### HACS (custom repository)
1. HACS → Frontend → ⋮ → Custom repositories
2. Add `https://git.naps.pt/naps62/ha-webrtc-doorbell-card`, category **Lovelace**
3. Install **WebRTC Doorbell Card**, then hard-refresh your browser.
### Manual
Copy `webrtc-doorbell-card.js` into `/config/www/` and register it as a Lovelace resource of type `module` pointing at `/local/webrtc-doorbell-card.js`.
## Usage
```yaml
type: custom:webrtc-doorbell-card
entity: camera.doorbell # any camera that webrtc-camera can stream
unlock_action: # optional — omit to hide the unlock button
action: perform-action
perform_action: dahua.vto_open_door
data:
door_id: 1
target:
entity_id: camera.doorbell_main
unlock_icon: mdi:key # optional, defaults to mdi:key
mode: webrtc # optional, passed through to webrtc-camera
```
A `panel: true` view works best:
```yaml
title: Doorbell
path: doorbell
icon: mdi:doorbell-video
panel: true
cards:
- type: custom:webrtc-doorbell-card
entity: camera.doorbell
unlock_action:
action: perform-action
perform_action: dahua.vto_open_door
data: { door_id: 1 }
target: { entity_id: camera.doorbell_main }
```
## How it works
The card creates an inner `webrtc-camera` configured with `media: video,audio,microphone` and `ui: false`, then overlays its own three buttons.
- **Mic capture** — a one-time monkey-patch of `navigator.mediaDevices.getUserMedia` stores a reference to the mic stream and starts every audio track disabled. Pressing Answer flips the tracks' `enabled` flag.
- **Incoming audio** — toggled via `video.muted` on the inner card's `<video>` element.
This means the browser's mic permission prompt fires once on stream start (not on every Answer click), and Answer/Hangup feel instant.
### Caveats
- The `getUserMedia` patch is global to the page. If another card in the same page also requests audio, the most recent stream wins.
- iOS Safari ignores `navigator.vibrate()`, so iPhone users get the visual feedback only.
- If you reload the dashboard mid-call, state resets to muted.
## License
MIT — see `LICENSE`.
+5
View File
@@ -0,0 +1,5 @@
{
"name": "WebRTC Doorbell Card",
"filename": "webrtc-doorbell-card.js",
"render_readme": true
}
+240
View File
@@ -0,0 +1,240 @@
// webrtc-doorbell-card v0.4
//
// A Lovelace card that wraps AlexxIT's `webrtc-camera` and adds proper
// answer/end-call buttons that toggle BOTH directions of audio at once
// (incoming via video.muted, outgoing via mic track .enabled), plus an
// optional unlock button that fires a configurable HA action.
//
// Repository: https://git.naps.pt/naps62/ha-webrtc-doorbell-card
(() => {
if (window.__webrtcDoorbellPatchInstalled) return;
window.__webrtcDoorbellPatchInstalled = true;
const md = navigator.mediaDevices;
if (!md || !md.getUserMedia) return;
const orig = md.getUserMedia.bind(md);
md.getUserMedia = async function (constraints) {
const stream = await orig(constraints);
if (constraints && constraints.audio) {
window.__webrtcDoorbellMicStream = stream;
// Start disabled — user clicks Answer to enable
stream.getAudioTracks().forEach((t) => { t.enabled = false; });
}
return stream;
};
})();
const VERSION = '0.4.0';
class WebrtcDoorbellCard extends HTMLElement {
setConfig(config) {
if (!config || !config.entity) {
throw new Error('entity is required');
}
this._config = config;
if (!this._rendered) this._render();
}
set hass(hass) {
this._hass = hass;
if (this._inner) this._inner.hass = hass;
}
async _render() {
this._rendered = true;
this.innerHTML = '';
this.style.cssText = 'display:block;width:100%;height:100%;';
const wrap = document.createElement('div');
wrap.style.cssText = 'position:relative;width:100%;height:100%;';
this.appendChild(wrap);
const helpers = await window.loadCardHelpers();
const inner = helpers.createCardElement({
type: 'custom:webrtc-camera',
entity: this._config.entity,
media: 'video,audio,microphone',
mode: this._config.mode || 'webrtc',
ui: false,
background: true,
});
inner.style.cssText = 'display:block;width:100%;height:100%;';
if (this._hass) inner.hass = this._hass;
this._inner = inner;
wrap.appendChild(inner);
const overlay = document.createElement('div');
overlay.style.cssText = [
'position:absolute',
'left:0', 'right:0', 'bottom:24px',
'display:flex', 'justify-content:center', 'gap:24px',
'z-index:10', 'pointer-events:none',
].join(';');
wrap.appendChild(overlay);
if (this._config.unlock_action) {
this._unlockBtn = this._makeBtn(
this._config.unlock_icon || 'mdi:key',
'rgba(0,0,0,0.55)',
() => this._unlock(),
);
this._unlockIcon = this._unlockBtn.querySelector('ha-icon');
overlay.appendChild(this._unlockBtn);
}
this._answerBtn = this._makeBtn('mdi:phone', '#15803d', () => this._answer());
this._endBtn = this._makeBtn('mdi:phone-hangup', '#b91c1c', () => this._end());
this._endBtn.style.display = 'none';
overlay.appendChild(this._answerBtn);
overlay.appendChild(this._endBtn);
this._waitForVideo().then((v) => { if (v) v.muted = true; });
}
_makeBtn(icon, bg, onClick) {
const btn = document.createElement('button');
btn.type = 'button';
btn.style.cssText = [
`background:${bg}`,
'border:none', 'color:white',
'width:72px', 'height:72px', 'border-radius:50%',
'cursor:pointer', 'pointer-events:auto',
'display:flex', 'align-items:center', 'justify-content:center',
'box-shadow:0 4px 12px rgba(0,0,0,0.4)',
'-webkit-tap-highlight-color:transparent',
'transition:transform 120ms ease',
'padding:0',
].join(';');
const ic = document.createElement('ha-icon');
ic.icon = icon;
ic.style.cssText = '--mdc-icon-size:36px;color:white;pointer-events:none;';
btn.appendChild(ic);
btn.addEventListener('pointerdown', () => { btn.style.transform = 'scale(0.9)'; });
const release = () => { btn.style.transform = ''; };
btn.addEventListener('pointerup', release);
btn.addEventListener('pointercancel', release);
btn.addEventListener('pointerleave', release);
btn.addEventListener('click', (e) => { e.stopPropagation(); onClick(); });
return btn;
}
_unlock() {
const action = this._config.unlock_action;
if (!action || !this._hass) return;
if (navigator.vibrate) navigator.vibrate([40, 60, 40]);
this._fireAction(action);
this._flashUnlock();
}
_fireAction(action) {
// Minimal action dispatcher — supports the common shapes used in HA
// tap_action configs. Only `call-service` / `perform-action` is wired
// up; other actions are dispatched as a hass-action DOM event so HA's
// own action handlers can pick them up if available.
const type = action.action || action.service ? 'call-service' : null;
if ((action.action === 'call-service' || action.action === 'perform-action' || (!action.action && action.service))) {
const svc = action.service || action.perform_action;
if (!svc) return;
const [domain, name] = svc.split('.');
const data = action.data || action.service_data || {};
const target = action.target || {};
this._hass.callService(domain, name, data, target);
return;
}
// Fallback: dispatch a hass-action event the way HA expects
const event = new Event('hass-action', { bubbles: true, composed: true });
event.detail = { config: { tap_action: action }, action: 'tap' };
this.dispatchEvent(event);
}
_flashUnlock() {
const btn = this._unlockBtn;
const icon = this._unlockIcon;
if (!btn || !icon) return;
const origBg = 'rgba(0,0,0,0.55)';
const origIcon = this._config.unlock_icon || 'mdi:key';
icon.icon = 'mdi:check-bold';
btn.style.backgroundColor = '#15803d';
btn.animate(
[
{ transform: 'scale(0.9)', offset: 0 },
{ transform: 'scale(1.18)', offset: 0.35 },
{ transform: 'scale(1)', offset: 1 },
],
{ duration: 700, easing: 'ease-out' },
);
clearTimeout(this._unlockResetTimer);
this._unlockResetTimer = setTimeout(() => {
btn.style.backgroundColor = origBg;
icon.icon = origIcon;
}, 1000);
}
async _waitForVideo() {
for (let i = 0; i < 60; i++) {
const v = this._findVideo();
if (v) return v;
await new Promise((r) => setTimeout(r, 250));
}
return null;
}
_findVideo() {
if (!this._inner) return null;
return (
this._inner.querySelector?.('video') ||
this._inner.shadowRoot?.querySelector?.('video') ||
null
);
}
_setActive(active) {
const v = this._findVideo();
if (v) v.muted = !active;
const stream = window.__webrtcDoorbellMicStream;
if (stream) {
stream.getAudioTracks().forEach((t) => { t.enabled = active; });
}
}
_answer() {
if (navigator.vibrate) navigator.vibrate(30);
this._setActive(true);
this._answerBtn.style.display = 'none';
this._endBtn.style.display = 'flex';
}
_end() {
if (navigator.vibrate) navigator.vibrate(30);
this._setActive(false);
this._answerBtn.style.display = 'flex';
this._endBtn.style.display = 'none';
}
getCardSize() { return 6; }
static getStubConfig() { return { entity: 'camera.doorbell' }; }
}
if (!customElements.get('webrtc-doorbell-card')) {
customElements.define('webrtc-doorbell-card', WebrtcDoorbellCard);
}
window.customCards = window.customCards || [];
if (!window.customCards.find((c) => c.type === 'webrtc-doorbell-card')) {
window.customCards.push({
type: 'webrtc-doorbell-card',
name: 'WebRTC Doorbell',
description: 'Doorbell with answer/end call (toggles both audio directions)',
documentationURL: 'https://git.naps.pt/naps62/ha-webrtc-doorbell-card',
});
}
console.info(
`%c WEBRTC-DOORBELL-CARD %c v${VERSION} `,
'color:white;background:#15803d;font-weight:700',
'color:#15803d;background:white;font-weight:700',
);