Smart home (Home Assistant / MQTT)¶
MqttService / MqttPublisher — Immortal can expose a Portal to
Home Assistant over MQTT Discovery, so the device shows up
as something you can see and control.
What it exposes¶
The publisher reuses the state Immortal already holds and surfaces it to Home Assistant:
- Presence and screen state (
PresenceHub) — see Presence below. - Ambient sensors — temperature and light, where the model has them. See Ambient sensors.
- Now-playing media (
NowPlayingHub) — see Multi-room audio. - Battery (on models that have one).
- IP address (a diagnostic sensor).
Presence¶
The Presence entity reads Meta's own camera detection. Portal's presence service logs a
heartbeat about every 30 seconds while it can see someone, and goes quiet when the room
empties — so Immortal tails the system log and reports presence from whether that beat is still
arriving (PortalPresenceMonitor). It's the real signal, not an inference, and it keeps working
while the photo frame is pinned on.
This needs the READ_LOGS permission, which the provisioning kit already
grants for the fleet agent — so on a provisioned Portal there's nothing to do. If your Portal was
set up before that grant existed, re-run the provisioner.
Where the detector can't be read — no grant, or a Portal that doesn't log it — the entity falls
back to inferring presence from the screensaver's dream/sleep lifecycle. The entity's
source attribute says which you're getting:
source |
Meaning |
|---|---|
portal |
Meta's own camera detection. Direct observation of the room. |
proxy |
Inferred from the dream/sleep lifecycle. Blind while the frame is pinned on — see the confident attribute. |
screen |
Last resort: whether the panel is on. Used only before the proxy has seen a transition. |
Silence is never read as an empty room: until a first heartbeat arrives the log reader says nothing at all and the proxy stays in charge, so a Portal that never reports presence doesn't sit in Home Assistant claiming the room is permanently empty.
Turn it off with Use the Portal's own detector under Settings → Immortal to go back to the proxy. It lives there rather than under Home Assistant because it isn't MQTT-specific: the fleet agent and multi-room audio read the same presence signal, so they get the better reading too — whether or not Home Assistant is set up.
Ambient sensors¶
Immortal publishes the Portal's ambient sensors as Home Assistant entities:
| Entity | Unit | Notes |
|---|---|---|
| Temperature | °C | Has a calibration offset — see below. |
| Ambient light | lx | |
| Humidity | % | Only if the model has the hardware. |
| Pressure | hPa | Only if the model has the hardware. |
Sensors are auto-detected, so only the ones your Portal physically has ever appear — no entity is advertised for hardware that isn't there, and switching the feature off clears the entities from Home Assistant rather than leaving them behind as "unavailable".
Calibrate the temperature
A temperature sensor inside a powered device reads its own waste heat, so it runs a few degrees warm. Put a thermometer next to the Portal, then set Temperature offset (Settings → Home Assistant (MQTT)) to the difference — it applies immediately, without waiting for the room to change.
Readings are rate-limited rather than forwarded raw: a value has to move by a meaningful amount and wait out an interval before it's published, so a twitchy light sensor doesn't bury the broker. A large jump — someone switching a lamp on — skips most of that wait, because that's the event an automation is actually waiting for.
Camera¶
A Portal can be a camera for Home Assistant — live H.264 video over RTSP, with optional sound. It is off by default, and the consent is deliberately on the device only: turn it on under Settings → Home Assistant (MQTT) → Camera. Nothing arriving over MQTT can enable it, and while it is off the camera is never opened.
Once on, three entities appear: a Camera streaming switch, a Camera sound switch, and a
Stream URL diagnostic sensor telling you where to point a player — typically
rtsp://<portal-ip>:8554/.
It plays directly in VLC. For a Home Assistant dashboard, feed it through go2rtc — the WebRTC Camera card is the usual route:
The Portal has to be showing Immortal¶
This is the one real constraint, and it comes from the platform rather than from us. Android 9 gives the camera to the foreground app only, so the stream runs while the Portal is sitting on its home screen or photo frame — its normal state — and stops the moment somebody opens another app on it.
That means two practical things:
- You can't watch the stream on the Portal that's producing it. Opening a player there puts that player in the foreground, which takes the camera away. Watch from anywhere else.
- Using the Portal pauses the stream, briefly. It comes back on its own within a few seconds of returning to the launcher — the switch stays on, the RTSP address doesn't change, and the picture resumes. Viewers see a gap, not a dead stream.
A foreground service isn't enough to avoid this, an overlay window isn't either, and neither is
forcing the CAMERA app-op with adb; all three were measured on a Portal Go. docs/design/camera-streaming.md
records what was tried.
Other points worth knowing¶
- Sound is optional and separate. Turn on Camera sound (the Home Assistant switch, or Settings → Home Assistant (MQTT)) and the stream carries audio as well. It's off by default, and a viewer that only wants a picture can take the video track alone.
- Muting the microphone silences the stream. Not quietened — no audio leaves the device at
all while the
Microphone muteswitch is on. That switch is the truth. - Someone talking takes the microphone back. A live intercom announcement, or recording a voice note, outranks a background stream: the sound drops out for the duration and the video carries on.
- Streaming does not survive a reboot. The camera permission persists, the live stream doesn't — a Portal that loses power comes back idle rather than quietly broadcasting a room.
- Home Assistant can start the stream, but never enable the camera. The switch only works within the consent given on the device.
- While it runs, the camera is held. The photo frame's wave-to-advance gesture and a Portal call want the same camera, so they can't overlap with streaming.
- The Portal's green camera light is on for as long as the stream is, alongside a permanent notification. That light is wired below the operating system, so it's a signal Immortal can neither fake nor forget to turn off — which is why there's no in-app badge competing with it.
- Anyone with broker credentials can start it. The same trust assumption as notifications — but with a camera on the other end of it, so treat broker access accordingly.
What it can control¶
- Screen on/off (
ScreenControl, which uses the screen-off device-admin granted during provisioning) — wake or sleep a Portal's display as part of an automation. - Open a URL, an installed package, or a Home Assistant dashboard path on the Portal — the same string grammar the screensaver picker accepts.
- Screensaver — show the photo frame on demand (a
Screensaverbutton entity). This is the same photo-frame surface the launcher's header button opens;Homedismisses it. Note it's the in-app photo frame, not the system dream, so theScreen statesensor staysinteractivewhile it's showing. - Notifications — push a toast (with optional image, sound, and a tap target) from any Home Assistant automation. See below.
Notifications¶
Immortal renders a Portal-native bottom toast in response to MQTT-driven notify messages from Home Assistant. Two ways to fire one — pick whichever fits the automation:
Simple alerts: notify.send_message¶
Each configured Portal shows up in HA's notify picker. For a plain text alert, use the
standard send_message action:
This produces a bottom toast at the default duration (6s). Only the message reaches the
device — Home Assistant's MQTT notify entity (2024.7+) doesn't pass title or data: through
the command_template, so use the raw-topic path below for anything richer.
Rich alerts: mqtt.publish¶
For doorbells, motion events, or anything wanting an image / sound / tap-action, publish the full JSON payload directly to the device's notify topic:
action: mqtt.publish
data:
topic: immortal/<device-id>/notify/set
payload: |
{
"title": "Front door",
"message": "Motion at 6:42pm",
"image": "http://homeassistant.local:8123/local/snapshot.jpg",
"sound": "http://homeassistant.local:8123/local/sounds/doorbell.mp3",
"on_tap": "lovelace/security",
"duration": 8
}
All fields are optional. Full behavior rules in
docs/design/mqtt-notifications.md.
Payload fields¶
| Field | Type | Default | Notes |
|---|---|---|---|
title |
string | "" |
Bold line at the top of the toast. One line, ellipsizes if too long. |
message |
string | "" |
Body text below the title. Wraps to two lines, then ellipsizes. |
image |
string | null |
http(s):// URL → fetched, or data:image/...;base64,... → decoded inline. Decode is downsampled to ≤512px to stay safe on Portal heap. |
sound |
string | null |
http(s):// URL or local URI fed to MediaPlayer. Plays through STREAM_ALARM — see Portal volume quirk below. |
position |
enum | "bottom" |
"top" or "bottom". Bottom matches Portal's own ephemeral-UI gravity. See note on top-overlap below. |
duration |
int | 6 |
Auto-dismiss timeout for the visual toast, in seconds. 0 = no auto-dismiss; toast stays until tapped. Sound has its own lifecycle. |
volume |
float | 1.0 |
Sound volume 0.0–1.0 of the alarm-stream max. Bounded by the user's system alarm-volume slider. |
wake_screen |
bool | true |
If the screen is off when the notify arrives, wake it so the toast is visible. Set false for low-priority chimes that shouldn't wake a sleeping room. |
on_tap |
string | null |
Tap target. URL, installed package name, or HA dashboard path. See Tap targets below. |
Special cases¶
- Empty payload (
{}or empty string): no-op. An automation that drops all template fields can't accidentally produce a "ghost" toast. - Sound-only:
soundpresent, bothtitleandmessageempty → no visual, just audio. Useful for chimes that shouldn't change what's on screen. - Replace, don't stack: a new toast arriving while one is showing replaces
it. The previous sound keeps playing unless the new payload has its own
sound. - DND audio gate: when the system is in Do Not Disturb, sound is suppressed but the toast still renders. The visual is the polite signal that the device received the alert.
- Acknowledgement-required:
duration: 0means the toast stays until the user taps it. Combine withon_tapso the tap also navigates.
Tap targets (on_tap)¶
The on_tap field is what the toast does when tapped (in addition to
dismissing). The Portal's router accepts four forms by prefix:
| You write | Behavior |
|---|---|
http://... / https://... |
Opens in the default browser via ACTION_VIEW. |
homeassistant://... |
Opens directly in the HA companion app (HA's custom URI scheme). |
An installed package name, e.g. com.android.chrome |
Launches that app's main activity. |
| Anything else (bare path) | Treated as an HA dashboard path. Routed via homeassistant://navigate/<path> to the installed HA app. |
For HA dashboards, all of these mean the same thing:
| Input | Goes to |
|---|---|
lovelace |
Main HA dashboard. |
lovelace/security |
The security view inside the main dashboard. |
lovelace-doorbell/0 |
First view of the custom lovelace-doorbell dashboard. |
/lovelace/security |
Leading slash trimmed → same as above. |
http://homeassistant.local:8123/lovelace/security |
Host stripped → same as above. |
homeassistant://navigate/lovelace/security |
Passed through verbatim. |
You can find a dashboard's slug at Settings → Dashboards (the URL column), and view slugs inside each dashboard's URL bar.
Requirements: the HA companion app must be installed (either
io.homeassistant.companion.android or the minimal F-Droid flavor, which is
what no-GMS Portals use), and the user must be logged in. If neither is true,
the tap is a no-op with a logcat warning.
Position top overlaps the launcher header
The default position: "bottom" lands the toast safely below Immortal's home
grid. position: "top" renders the toast in the same vertical band as the
launcher's clock / photos / weather row and partially obscures it — fine
when something needs the user's attention right where their eyes already
are, but worth knowing if you're tempted to use top by default.
Media hosting¶
The Portal fetches images and sounds anonymously over HTTP — no bearer token, no
session cookie. The simplest place to host them on Home Assistant is the auth-less
/config/www/ directory, which serves at http://homeassistant.local:8123/local/...:
/config/www/sounds/doorbell.mp3 → /local/sounds/doorbell.mp3
/config/www/snapshots/front-door.jpg → /local/snapshots/front-door.jpg
Camera snapshots with /api/camera_proxy/... URLs won't work directly — that path
needs a bearer token the Portal doesn't have. Either save the snapshot to www/ first (use the
camera.snapshot service in your automation) or embed a long-lived access token in the URL.
Using HA media-source URIs¶
If you'd rather keep media in HA's structured media library (/media/, surfaced
under Media in the HA UI) than copy into /config/www/, resolve a signed URL
at automation time and pass that to the notify payload. Signed URLs expire
(default ~30 minutes), so the resolve must happen as part of the firing
automation — caching the URL won't work.
Wrap the resolve + publish into a reusable script so each automation is a single call:
# scripts.yaml
portal_notify:
alias: "Portal: send rich notification"
fields:
topic: { description: "MQTT notify topic, e.g. immortal/<device-id>/notify/set" }
title: { description: "Bold line" }
message: { description: "Body text" }
sound_media: { description: "media-source:// URI for the chime" }
image_media: { description: "media-source:// URI for the image (optional)" }
on_tap: { description: "HA dashboard path, URL, or package name (optional)" }
duration: { description: "Auto-dismiss seconds; 0 = stays until tapped", default: 6 }
sequence:
- variables:
base_url: "http://homeassistant.local:8123"
sound_url: ""
image_url: ""
- if: "{{ sound_media is defined and sound_media }}"
then:
- action: media_source.resolve_media
data:
media_content_id: "{{ sound_media }}"
response_variable: sound_r
- variables:
sound_url: "{{ base_url }}{{ sound_r.url }}"
- if: "{{ image_media is defined and image_media }}"
then:
- action: media_source.resolve_media
data:
media_content_id: "{{ image_media }}"
response_variable: image_r
- variables:
image_url: "{{ base_url }}{{ image_r.url }}"
- action: mqtt.publish
data:
topic: "{{ topic }}"
payload: >-
{
"title": {{ (title | default('')) | tojson }},
"message": {{ (message | default('')) | tojson }},
"sound": {{ sound_url | tojson }},
"image": {{ image_url | tojson }},
{% if on_tap is defined and on_tap %}"on_tap": {{ on_tap | tojson }},{% endif %}
"duration": {{ duration | default(6) }}
}
Then automations call it with media-source URIs directly:
action: script.portal_notify
data:
topic: immortal/<device-id>/notify/set
title: "Front door"
message: "Motion at 6:42pm"
sound_media: media-source://media_source/local/sounds/doorbell.mp3
image_media: media-source://media_source/local/snapshots/front-door.jpg
on_tap: lovelace/security
Edit base_url if your HA instance is reached at a different hostname. If you
only have one Portal, hardcode the topic inside the script and drop it from
the field list.
Portal volume quirk¶
The Portal has a single "media volume" slider that drives almost every audio stream: music,
ring, notification, system. The only streams that are independent on Portal are call and
alarm. So notify sounds route through STREAM_ALARM — that's the only way to get a chime
that's loud-by-default and doesn't drift when you change Spotify's volume. The alarm slider
becomes your "notification volume" on this hardware; set it once to a level that's audible
from across the room and forget it. Do Not Disturb still silences notify sounds (Immortal
gates the audio on the system DND state before playback) — the visual toast still renders.
Setup¶
It's a long-running, reboot-proof on-device foreground service that mirrors the fleet agent, and it's off until you configure a broker. An un-configured device never opens a connection.
Configure it under Immortal → Settings → Home Assistant (MQTT): turn on the toggle and enter
your broker host (default port 1883) and, if your broker requires it, a username and
password. The Portal then appears automatically under Settings → Devices in Home Assistant —
no YAML. Its device name is shared with the fleet agent, so a Portal shows up under one name
everywhere, and a live status line tells you whether the connection is up.
Full walkthrough
See the Home Assistant & MQTT setup guide for prerequisites (Mosquitto add-on, MQTT integration), an example automation, and troubleshooting.