Lovelace card that streams a Scrypted camera over WebRTC directly, with two-way audio. No iframe, no Scrypted console, no CSS injection, no scraped selectors.
The card never talks to Scrypted directly — a browser is not allowed to, see Why there is
no base_url below. It goes through Home Assistant, and it picks how by itself:
The card uses the
koush/ha_scryptedintegration's proxy when that integration is installed. Otherwise the Scrypted add-on. If you set Scryptedusername/password, always the add-on — that is the only route where they mean anything. If the proxy is installed but cannot be resolved, the add-on is tried anyway before the card gives up.
So one of these has to be true, and there is nothing to configure for either:
- Scrypted as a Home Assistant add-on, on a Supervised or Home Assistant OS install. The card asks the Supervisor for an ingress session over the websocket Home Assistant already authenticated, so no token or password ends up in your dashboard.
- or the
koush/ha_scryptedintegration installed through HACS and pointed at your Scrypted — which can be anywhere: its own container, a NAS, another host. This also works on HA Container and HA Core, which have no Supervisor. The cost: everything the card sees goes through the account that integration is configured with, sousername/passwordare refused on this route — see Card config.
With debug: true, the card shows which route it took next to the version number, top
right, while it is paused: v0.5.0 · integration or · ingress.
Administrator rights are not required on either route. They used to be, and this is the point people carry over from older versions: the card no longer asks Home Assistant for the list of installed add-ons, which was the only admin-gated call it made.
Tested against Scrypted 0.143.0. Scrypted's RPC and RTCSignalingSession are
internal interfaces rather than a documented public API, so a Scrypted update can
break this card. See Known risks at the end.
Since 2026.11, HACS is built into Home Assistant as the Marketplace. Add this
repository as a custom repository: Settings → Marketplace → Add from link → this
repository's URL, type Dashboard. Then install "Scrypted Camera Card" and reload the
browser. The Marketplace installs from the latest GitHub release and serves the card
from /local/community/scrypted-camera-card/.
On a storage-mode dashboard — the default, edited through the UI — the Marketplace registers the dashboard resource itself and there is nothing to add under Settings → Dashboards → Resources.
On a YAML-mode dashboard the Marketplace cannot add resources, so add it yourself in
configuration.yaml:
lovelace:
mode: yaml
resources:
- url: /local/community/scrypted-camera-card/scrypted-camera-card.js?v=0.5.9
type: moduleNote the ?v=. The Marketplace appends its own cache-busting query when it registers a
resource, and a hand-written entry gets none — so without it a browser or the Home
Assistant service worker will keep serving the previous bundle after an update, which
looks exactly like the update not having worked. Bump the value whenever you update.
On Home Assistant before 2026.11, HACS works the same way: HACS → three-dot menu →
Custom repositories, category Dashboard; HACS served resources from
/hacsfiles/scrypted-camera-card/. That path still works on 2026.11, but Home Assistant
raises a repair asking to switch YAML resources to /local/community/.
To check which version a dashboard is actually running, set debug: true and look at
the top right corner of the card while it is not streaming: the version is printed there.
That is the quickest way to tell a failed update from a cached one. Without debug the
label is hidden, so it does not sit over the snapshot like a watermark.
Build it yourself (see Build) or take scrypted-camera-card.js from a release,
then:
cp scrypted-camera-card.js <config>/www/and register it once under Settings → Dashboards → Resources as a JavaScript
module pointing at /local/scrypted-camera-card.js. Note that this path has no
version in it, so a browser or the HA service worker will happily keep serving an
old copy after an update — append a ?v= query and change it whenever you replace
the file.
- Resolves a base URL on the Home Assistant origin. On the ingress route that is a
Supervisor ingress session obtained over the existing authenticated HA websocket
(
hass.callWS), so no access token goes into the dashboard config. On the integration route it is the proxy path thekoush/ha_scryptedintegration publishes on its sidebar panel, re-read on every connect because the token in it changes whenever that integration reloads. - Connects
@scrypted/clientagainst that base URL. Both routes answer/loginwith an authorization without being given credentials — the add-on authenticates the ingress user, the proxy authenticates server-side — so no Scrypted credentials are needed either. - Implements
RTCSignalingSessionin the browser (src/signaling.js) and hands it todevice.startRTCSignalingSession(). The plugin drives the exchange. - Attaches the resulting stream to a plain
<video>. - Talkback: the audio transceiver is negotiated as
sendrecvwhen the camera exposesIntercom, but carries no track until the mic button is pressed. That way no microphone prompt appears just from viewing.
npm install
npm run buildProduces dist/scrypted-camera-card.js (~167 kb). npm run watch rebuilds on
change with a sourcemap. dist/ is not tracked in git — releases carry the bundle
as an asset, built by .github/workflows/release.yml.
Bundling goes through build.mjs rather than plain esbuild flags, for two
reasons that are not optional:
@scrypted/clienthas nobrowserfield and noexportsmap, so esbuild resolves its Node paths and pulls inevents,net,streamandfollow-redirects. Those are stubbed - none of them is on a code path a browser executes.- The package selects its HTTP implementation by trying to require those
builtins and falling back to
fetchwhen the require throws. Stubbing alone would make the require succeed and thus select the Node path, which then fails at runtime.define: { 'process.arch': '"browser"' }is the package's own browser switch and forces the correct branch. Verified in the output:try{throw new Error}catch{ot=Ka.domFetch}.
The stub plugin prints what it neutralised on every build. If that list grows after a dependency update, check the new entry before assuming it is harmless.
To use a self-built bundle, follow Manual under Install.
hacs.json names the file HACS looks for, and it must match the release asset
exactly. Cutting a release:
- Bump
versioninpackage.jsonand add aCHANGELOG.mdentry under a heading for the new version. The workflow takes the topmost##section as the release notes, so the new version has to be at the top — an empty[Unreleased]heading above it would be picked instead, and the release would fall back to generated notes. - Tag and push, e.g.
git tag v0.2.0 && git push origin v0.2.0. That is all — do not create the release by hand. .github/workflows/release.ymlbuilds the bundle and then creates the release withscrypted-camera-card.jsattached.
The order matters. The release is created last, so a failed build publishes nothing instead of leaving a release without its asset — which is what HACS would install as broken. The bundle is deliberately not committed for the same reason: there is no stale copy that could be shipped in place of a fresh one.
type: custom:scrypted-camera-card
device: "121" # Scrypted device id (as in /#/device/121) or its name
name: Eingang # optional, shown in the control bar
aspect_ratio: 16 / 9 # optional, shape of the card (not of the picture), any CSS aspect-ratio value
fit: cover # optional, cover (default, crop to fill) or contain - see below
autoplay: false # optional, default false - see below
stop_when_hidden: true # optional, default true - stop the stream when the card leaves the screen
# debug: true # optional - shows the card's internal state while it is
# not streaming, see below
# destination: low-resolution # optional, see below
# source: Scrypted # optional - only when the default does not fit, see below
# username / password # add-on route only, see belowsource names the add-on or the integration entry, and you normally leave it empty.
There is no option for the route: the card decides that by the rule in Before you install,
and source is then read by whichever route it chose.
In the visual editor this is a list of what your Home Assistant actually has — each
koush/ha_scrypted entry by its Name, the Scrypted add-on, and "Automatic" for the usual case.
Anything the list cannot show, such as an add-on whose name does not contain "Scrypted", can
still be set in YAML.
Empty means "the obvious one": the only koush/ha_scrypted entry, or the add-on's usual slug
09e60fb6_scrypted. Set it when that is not enough:
- on the integration route it is that entry's Name — not the host, which is what Home
Assistant shows as the entry's title in its integrations list. The Name defaults to
Scryptedfor every entry, so if you have two, rename one first; two entries with the same name cannot be told apart by this card. - on the add-on route it is the add-on slug, for the rare install whose slug differs.
username / password are refused on the integration route, and that is deliberate
rather than an omission: the proxy replaces the authorization header on every request with the
integration's own, so credentials on the card would look like they scope it and scope nothing.
Setting them is therefore also what pins a card to the add-on route.
Upgrading from 0.4.x or earlier — this one breaks configs. addon, connection and
integration_title are gone with no alias. If your addon was the default slug or unset,
there is nothing to do. If you had set it to something else, rename the key to source —
otherwise the card looks for the default add-on and reports that it cannot read it. connection
and integration_title are simply ignored. Opening the card once in the visual editor removes
all three from the YAML.
autoplay decides only whether the card starts streaming when it first
loads. Default false: the card connects to Scrypted, shows a still image and
waits for the play button. Set it to true to stream immediately, which is how
the card behaved before this option was documented.
It deliberately does not govern anything after the first load. Once a stream has been started - by autoplay or by the button - the card keeps trying to hold it, and self-healing resumes it after an add-on restart, a dropped websocket or a network outage regardless of this setting. Pressing stop is what revokes that intent.
stop_when_hidden (default true): as soon as the card is no longer on screen —
another view or dashboard, scrolled out of sight, another browser tab, the app in the
background — the stream is stopped exactly as if stop had been pressed, so the camera
stream and its traffic end with it. Coming back shows the play button; an autoplay: true
card starts again by itself. Fullscreen does not count as leaving. Set it to false for
the previous behaviour: playback is only paused off screen and the session stays open, so
coming back is instant, at the cost of streaming for as long as the page stays open.
username / password are optional, apply to the ingress route only, and do two
different things depending on why you set them.
Left empty, the card connects as whoever the Scrypted add-on decides an ingress request is — usually an account with full access. Filled in, the card authenticates as that Scrypted user instead, so a viewer account restricted to a few cameras limits what the card can show. That is the reason to use them, and the setup worth having if other people in the household see this dashboard.
Two things to know before you do:
- The dashboard configuration is not a secret store. Any logged-in Home Assistant user can read it over the websocket API, so treat these as credentials for a restricted viewer account and nothing more.
- They do not restrict Scrypted itself. They scope what this card displays. A Home Assistant user who can load the card can also open Scrypted's own interface through the ingress URL, whatever the card authenticated as. See Known risks.
debug draws the card's own state over the picture: the flags that decide whether it
streams, retries or waits, a count of the recurring events it is caught in, and a log of the
last 40 state transitions with timestamps, newest first. It exists because the failures worth
reporting happen on wall-mounted tablets where no browser console is reachable, and guessing
from what the card looks like has a poor record.
It shows only while there is something to diagnose — while the card is connecting, retrying,
stopped, visibly waiting, or convinced it is not on screen. A live, healthy stream never carries
it, and it comes back by itself within a second of the stream stopping, so it can be left
switched on across the failure you are trying to catch. If it appears on top of a frozen
picture, that is not a glitch — the card is showing you why, and the flags line says which case
it is: vis=0 means the card believes it is off screen while you are looking at it; busy=1
with vis=1 means the watchdog has noticed the frames stopped moving.
Set it, reproduce whatever went wrong, read the overlay. The first line is stamped with the time
it was written — if that stamp is old, the card was detached and the numbers below it are stale.
The tally: line is usually the answer: a rising skipHidden or rearmHidden on a card you can
plainly see means the card believes it is off screen, which disables everything that would
otherwise recover it.
It is not offered in the visual editor, deliberately: it is an instrument, not a feature, and a switch that turns a camera into a wall of text is one nobody should find by accident. The editor does preserve it — an unknown key survives a visit — so setting it in YAML sticks.
aspect_ratio sets the shape of the card; fit decides what happens when the picture has a
different shape:
cover(default): the picture fills the card and the overflow is cropped.contain: the whole picture is shown, with black bars. For example, a portrait (9:16) doorbell in a 16:9 card:
type: custom:scrypted-camera-card
device: "107"
aspect_ratio: 16 / 9
fit: containSet aspect_ratio to the shape you want the card to have, not to the camera's shape. If both
have the same shape (say aspect_ratio: 9 / 16 for a 9:16 camera), there is nothing to crop or
pad, so cover and contain look identical and fit seems to do nothing.
Fullscreen always shows the whole picture, whatever fit says.
The button at the right end of the control bar, or a double-click on the picture, toggles fullscreen. There is nothing to configure.
- Desktop browsers, Android, iPad: the whole card goes fullscreen, so sound and talk stay
usable. The configured
aspect_ratiois ignored while fullscreen and the picture is letterboxed (object-fit: contain) instead of cropped. On phones the card asks for landscape orientation; browsers that do not allow it simply stay as they are. - iPhone: Safari offers no element fullscreen, only its native video player, so the picture opens there and the card's own controls (talk, sound) are not available until you leave it.
- The button is hidden when the browser offers neither, for example an embedded webview that does not implement fullscreen.
Pointing the card straight at https://scrypted.local:10443 looks like the obvious option and
is not possible from a dashboard. Measured, not assumed: Scrypted answers the CORS preflight
without an Access-Control-Allow-Origin header, so the browser drops the request before it
is sent — and @scrypted/client hardcodes withCredentials: true, which puts every request in
the credentialed mode where even a wildcard would be rejected. Nothing in a card can change
either half.
Both supported routes exist to avoid that: they end at a URL on the Home Assistant origin, so there is no cross-origin request at all. If you already run a reverse proxy in front of Home Assistant, serving Scrypted under a path on the same origin would work for the same reason — that is not implemented as an option here, and the integration route covers it without asking you to configure anything.
destination picks which of the camera's streams to pull. Accepted values are
local, remote and low-resolution — the same names Scrypted offers in its own
stream picker. Omit it and Scrypted decides, which is that picker's Default.
Three things worth knowing before you set it:
- It is a hint, not an instruction. Scrypted's own documentation says the value "may be used as a hint to determine which main/substream to send". A camera with no matching substream will return a different stream, and nothing will announce that — check the camera's log in Scrypted if you want to know what it actually sent.
- Setting it forces the WebRTC plugin's proxy path. Without it, a camera that speaks WebRTC natively is passed straight through; with it, the card always goes through the plugin.
- The card refuses to stream on two conditions rather than pretending: an unrecognised value, and a camera that offers no selectable stream at all (only the WebRTC plugin's own synthetic one). Both put the reason on screen. Remove the option in that case.
- Lifecycle.
disconnectedCallback()ends the RTC session, which releases the camera's talkback channel and stops the stream. AnIntersectionObserverplusvisibilitychangepauses the<video>element while the card is off screen. Both were impossible from inside an iframe. Note what this does not do: it saves no bandwidth. The WebRTC plugin'ssetPlaybackignores itsvideoflag entirely, so the camera keeps sending while the card is off screen and only local playback pauses. Keeping the session alive is deliberate - coming back to a visible card is then instant instead of a fresh negotiation. - Session refresh.
getRefreshAt()/extendSession()keep long-running sessions alive, and the ingress session is revalidated every 5 minutes. Both were silent death causes before. - No permissions delegation. The card runs in HA's own origin, so the
microphone needs no
allowattribute chain through two iframes. - No scraped selectors. Nothing breaks when Scrypted changes its Vuetify class names.
Bundling. Solved, see Build - but fragile by nature. It relies on
process.arch still being Scrypted's browser switch and on the stub list
covering everything Node-only. A @scrypted/client update can change either.
Unstable API. Scrypted's RPC and RTCSignalingSession are internal
interfaces, not a documented public API. They can change between Scrypted
releases. The upside over the old approach: a break shows up as a missing method
rather than as silently mis-styled DOM.
The visual editor's form. The config dialog is drawn with ha-form, Home
Assistant's own form element. It is an internal component with no compatibility
promise — the same class of dependency as Scrypted's RPC above — and a future HA
release can change or remove it. Only the visual editor is affected: if it fails to
load, the dialog says so and the card is still configurable in YAML, which stays the
supported path.
Talkback availability. The mic button only appears when the plugin negotiates
sendrecv audio, which requires the camera to expose Intercom. If it stays
hidden, check the camera in Scrypted for the Intercom interface.
A Home Assistant account is effectively Scrypted access. Creating an ingress session is not admin-gated, so any logged-in Home Assistant user can open Scrypted's full interface at its ingress URL — with this card, or with two lines in a browser console and no card at all. The card uses that door rather than opening it, and nothing it can do would close it. If someone should not reach Scrypted, that boundary belongs in Scrypted's own user management, or in not giving them a Home Assistant account — a dashboard card cannot enforce it.
The integration route depends on another project. It relies on koush/ha_scrypted keeping
its proxy at /api/scrypted/<token>/ and publishing it on its sidebar panel. That is a
third-party repository this card does not control, so a change there breaks this route — the
add-on route is unaffected, the card falls back to it when the proxy cannot be resolved, and
the error messages name the integration so it is clear whose contract broke.
Two properties of that route to know before choosing it: everything goes through the account
the integration is configured with, so per-card scoping via username / password is not
available; and the proxy view requires no Home Assistant authentication and carries its
bearer token in the URL path, so it is a second instance of the risk above rather than an
additional protection.
Verify first. getDeviceById / getDeviceByName and the exact
connectScryptedClient option set were taken from the current Scrypted sources.
Confirm against the version you run (0.143.0 here) before assuming a bug is in
this card.
