Initial commit: WebRTC Doorbell Card v0.4.0
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "WebRTC Doorbell Card",
|
||||||
|
"filename": "webrtc-doorbell-card.js",
|
||||||
|
"render_readme": true
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user