◀ CHICKENING OUT

Player 1  ·  16 min read

Saving an abandonware Facebook Portal from the bin: a kitchen dashboard with proper sound

3,259 words · 16 min read

Facebook launched the Portal in 2018, renamed itself Meta in 2021, and discontinued the product in 2022, switching off most of what made it work. Thousands went into drawers or skips. The hardware did not stop being good.

We had one in a cupboard, bought so the family could keep in touch with relatives overseas who had the same device. For video calls it was good: a wide angle camera, proper sound, and WhatsApp calling built in. There were storytelling features the kids loved, where a book is read aloud over the call with animations on top. Then each software update made it progressively buggier, with more resets and another round of re-authenticating WhatsApp, until it was more hassle than it was worth for the older relatives at the other end.

Then Meta did something unusual for a discontinued product. In 2026 it pushed a firmware update that unlocks ADB across the whole Portal line, announced by CTO Andrew Bosworth in a video showing a smart home hub he had vibe coded on one of them. There is a Hacker News thread on it, and Adafruit wrote up what it makes possible.

That update is the only reason any of this works. Without it the Portal is a sealed appliance talking to servers that no longer answer. Check your firmware before you plan anything: the Settings, Debug, ADB Enabled menu only appears on recent builds, and early reports had people following the on-screen prompt to a menu that was not there yet. Mine is on 1.47.4.

Mine now sits on its stand on the kitchen counter. It shows family photographs full screen, with the time, weather, the next thing on the calendar, an agenda, a shared to-do list and a kitchen timer. It plays music from the family CD rips through those speakers, and it switches the geyser on and off. It powers up in the morning and finds its own way to the dashboard with nobody touching it.

The idle state: a photograph filling the screen, with the time, weather and next event
The idle state: a photograph filling the screen, with the time, weather and next event
A touch reveals the widgets: weather, agenda, tasks, timer and music
A touch reveals the widgets: weather, agenda, tasks, timer and music

The dashboard is a small Nextcloud app, published at github.com/OWNER/nextcloud-wallboard under the AGPL, with the setup scripts alongside it.

What you are working with

Everything here was read off my own unit over adb. It is a second generation Portal, which the firmware calls omni, and it still identifies itself as Facebook hardware: the USB descriptor reads Manufacturer: Facebook, and every system package is com.facebook.*.

Model, codename Portal, omni / omni_prod
Display 1280x800 touchscreen, natively portrait at density 160
CPU arm64-v8a, so sideload arm64 APKs
RAM 2.8 GB
Storage 6.7 GB /data, around 4.3 GB free on a used device
OS Android 10, SDK 29, build QKQ1.210213.001
Firmware 1.47.4
Security patch 2020-08-05, frozen for good
WebView com.facebook.portal.webview, reporting Chromium 131
Google Play Services absent
Packages preinstalled 153, over 50 of them Meta’s own

Two of those lines shape everything that follows.

The WebView is Chromium 131, which is far newer than a 2019 device suggests, because Meta shipped its own WebView and kept updating it. CSS grid, :has() and modern JavaScript all work.

The security patch level is frozen at August 2020 and will never move. Treat the device as untrusted on your network and give it as little reach as you can.

The rest of the hardware is the reason to bother rather than buying a cheap tablet. It was built as a video calling device meant to fill a room, so the speakers are loud and clean enough to be the kitchen music system, and there is a far field microphone array. There is a physical camera shutter and a mic mute switch, both mechanical, so they work whether or not you trust what the software is doing. There is no battery.

Below is what you need to replicate the build, and the traps worth knowing before you start.

What you need

Two rules before you touch anything

Think twice before factory resetting a Portal. The out of box setup demands a Facebook or WhatsApp login and Meta documents no way to skip it. The WhatsApp route is dead: it offers a QR linking screen that never loads, because the device cannot fetch a linking token any more.

The Facebook route does still work. Mine reset itself at one point and was recovered by signing in with a spare Facebook account, so keep credentials for one that can log in. It is one login flow on a discontinued product, which nobody at Meta is obliged to keep running. Reset only if you have a reason and a working account to hand.

ADB switches itself off at every reboot. You turn it on at Settings, Debug, ADB Enabled, and it will be off again after the next power cut. So whatever you lock down, there has to be a route back to Android Settings that does not depend on ADB. Leave the stock Facebook launcher installed.

The Portal also has no battery, so any power cut is a hard shutdown and it cold boots when power returns.

The kiosk browser

You need a browser that starts on boot, runs full screen and stays on one page.

Fully Kiosk is the usual recommendation, but its free tier holds back the screensaver, popup handling and remote administration. WallPanel does all three for nothing. Install the arm64 APK, set the dashboard URL, and turn on Open On Device Boot and Prevent Screen Sleep.

Its settings open with a long press on a floating button, around two seconds. A short tap does nothing. Once you are finished, set Settings Transparent so the button disappears from view while staying clickable. Do not use Settings Disabled, which leaves an MQTT command as the only way back in.

Set Browser Zoom Level to 100%. If it is anything else, the page renders scaled while window.innerWidth, clientWidth and screen.width all still report the true width, so nothing in JavaScript reveals it. You get a layout anchored to the left with the right hand side pushed off the display, and nothing to explain it. The setting also only applies when the WebView is created, so change it and then fully restart the app rather than reloading the page.

Why not just point it at a Home Assistant dashboard

You can, and it works. On a wide screen there is one irritation.

Home Assistant wraps every dashboard in an app shell whose sidebar occupies a docked 256 pixels. On a 1280 pixel screen that is a fifth of the display given to nothing. The sidebar’s width and the content margin are written as inline styles by JavaScript at runtime, so themes, plugins and injected stylesheets all lose the cascade. The kiosk-mode plugin, theme variables and card-mod selectors will each fail for the same reason.

One thing does work. Home Assistant collapses the sidebar to an overlay once the viewport drops below about 870 CSS pixels, and Android’s display density is the lever that changes the viewport:

adb shell wm density 240        # 1280 x 160/240 = 853 CSS px, under the breakpoint
    adb shell wm density reset      # back to 160
    

The gutter disappears and the content fills all 1280 pixels. The catch is that density is a global scale factor, so everything renders at 1.5x: text and icons look noticeably soft, and usable height drops from 800 to 533 pixels, which crowds a dashboard badly. I put it back to 160 and looked for another approach.

Stop using the app shell instead. A plain page, served outside any dashboard framework, has no sidebar to fight. Nextcloud can render a page with none of its own navigation chrome, which frees the full width.

The panel

Nextcloud already held the photographs, the calendar and the to-do list, so serving the page from there puts it on the same origin as its own data.

Home Assistant is the platform: Nextcloud and Jellyfin both run on it as add-ons, and it serves the resized photographs as static files from /local/. The panel’s weather comes from met.no directly, and the calendar and to-do list come from Nextcloud’s own API. There is an optional bridge that reads Home Assistant entity states server side, which is how cameras and lights will arrive, but nothing on the panel depends on it today.

Install and point it at a user:

occ app:enable wallboard
    occ wallboard:owner alice --folders="Photos"
    occ wallboard:url                    # prints the panel URL
    

It is read from across the kitchen and spends nearly all its life untouched, so:

Security

The panel is a public page reached over a secret URL with a token in it, guarded by brute force protection and revocable with occ wallboard:rotate. Anyone who can touch the device can already read the screen.

A token in a URL is written to the web server’s access log in cleartext on every request and lands in browser history, so filter it out of your log format or treat the log as a secret.

An Android 10 board with a 2020 security patch level, a camera and microphones belongs on an isolated VLAN that can reach the two services it needs and nothing else.

Point the panel at a dedicated non-admin user with the photo folder and the household calendar shared to it. Nothing else is then reachable even in the worst case.

Photographs: pre-resize them

The obvious approach is to have Nextcloud generate a preview per photograph on demand. On a Raspberry Pi 4 that is not viable. For an image whose preview is not already cached, generation can hold a PHP worker for minutes, and a few concurrent requests will saturate every core and take Nextcloud down entirely.

Beware benchmarking this. Timing a handful of requests suggests about 11 milliseconds per image, because those photographs already had cached previews.

The fix removes the failure mode instead of rate limiting it. A script pre-resizes a sample of the library into flat JPEGs, one at a time, niced, with hard ImageMagick memory limits:

./tools/build-static-photos.sh          # 1000 photos at 1600x1000, about 110 KB each
    occ wallboard:config photo_static_url  http://your-ha:8123/local/panel/photos
    occ wallboard:config photo_static_path /homeassistant/www/panel/photos
    

Home Assistant serves them from /local/. photo_static_url is where the panel fetches images; photo_static_path is where Nextcloud reads the same folder to list them, so the manifest fetch stays same-origin.

ImageMagick is the wrong tool if you have a choice: libvips through vipsthumbnail does the same resizing in a fraction of the memory. And give every retry loop a delay. An early version advanced to the next photograph immediately when one failed to load, which turned a single server error into dozens of requests per second, enough to keep a small server unresponsive.

Photographs fill the screen when little would be lost and letterbox otherwise, against a blurred copy of themselves. A 16:10 panel showing a 3:4 phone photograph would crop away half of it, usually through somebody’s head.

Music through those speakers

It sounds like a mini boombox rather than a tablet, which is the whole reason to do this on a Portal.

Music comes from Jellyfin on the same Pi, holding CD rips reorganised from embedded tags into artist and album folders. If you are starting fresh, beets does that job properly.

The requirement was audio only, enforced by software rather than a toggle a child can flip. Jellyfin solves it with a library restricted user: the account the panel uses can see one music library and nothing else, and attempts to trigger a library scan return 403.

The panel shows the current track in the glance strip over the photographs, and a card with album art and a progress bar. The position advances locally between polls, so the bar moves once a second while the server is asked every fifteen.

Three things worth knowing if you build the same:

The kiosk browser cannot launch other Android apps. Both window.open and intent: links fail silently, because its WebView has no handler for them. Anything reachable from the dashboard has to be reachable over HTTP.

Open the library in an iframe. A kiosk browser has no address bar and no back button, so navigating away strands the panel with no route home. Jellyfin sends no X-Frame-Options, so it frames cleanly.

Do not remove that iframe when you close the overlay. Removing it destroys the audio element inside and the music stops the instant you go back to the dashboard. Hide it with opacity instead, which keeps it a live, painted document that carries on playing.

Calendar, tasks and weather

Calendar and weather need no explanation beyond configuration. Tasks have two traps.

A calendar only accepts the component types it was created with. A calendar made for appointments is VEVENT only and refuses tasks. The list stays empty and nothing on the panel tells you why. Make one that takes tasks:

occ dav:create-calendar alice tasks     # VEVENT, VTODO, VJOURNAL
    occ wallboard:config task_calendar tasks
    occ wallboard:config allow_task_writes true
    

Adding items and ticking them off from the panel is off by default, because it means anyone who can reach the panel URL can edit the list, which in a family kitchen is what you want.

Nextcloud blocks its own HTTP client from reaching private addresses, as SSRF protection. Any integration pointed at another service on your LAN fails with “violates local access rules” until you opt out per request.

Making it survive real life

Set the default home app, or it waits for a human. If Android has no default configured it shows a chooser at every boot. Check it, noting that the query needs the action as well as the category or it returns nothing even when a default is set:

adb shell cmd package resolve-activity -a android.intent.action.MAIN \
      -c android.intent.category.HOME --brief
    adb shell cmd package set-home-activity xyz.wallpanel.app/.ui.activities.BrowserActivityNative
    

Check for a screen lock too. A PIN would strand it on a lock screen every morning. Then reboot and confirm from the server side that it starts polling on its own, with the device untouched. Mine reaches the photo feed 60 to 90 seconds after power on.

Make the panel reload itself when you deploy. A kiosk browser loads the page once and never navigates again, so it will run the JavaScript it first loaded forever. The panel now receives the app version with its data and reloads when that changes. Without something like this, every fix you deploy sits on the server while the panel carries on as before.

Dim it at night, in the page. The panel darkens itself on a schedule, applying a filter to its own content between configurable hours:

occ wallboard:config dim_start 21
    occ wallboard:config dim_end 7
    occ wallboard:config dim_level 8      # percent brightness overnight
    

Doing it in the page rather than in the kiosk browser means it behaves the same on any device and needs nothing installed alongside. At 8 percent you cannot read the screen, so treat it as off rather than dimmed. What it buys over cutting the power is that it needs no extra hardware and comes straight back at the scheduled hour with no boot to wait for.

The backlight stays lit, so the panel gives off a slight glow. In a kitchen nobody notices. In a bedroom you would.

If you need the screen properly dark, put it on a smart plug and switch the power off overnight. The unattended boot above is what makes that painless: the panel comes back by itself, so the only cost is the warm-up. Set the plug to switch on a couple of minutes before you want the screen up.

Controlling something, carefully

The geyser is the first thing the panel switches rather than displays. It is a Tuya wifi contactor, driven through Home Assistant’s localtuya integration, and it appears on the panel as a card with its state and a switch. A geyser is a hot water cylinder, and the largest single load in most houses, so being able to see and change it from the kitchen is a useful thing on the screen.

Actuating something from a page with no login deserves more care than reading from one. The controls are gated four ways:

The Home Assistant token stays on the server throughout. The panel talks only to Nextcloud, which talks to Home Assistant, so nothing on the device carries a credential that would survive being lifted off it.

occ wallboard:config ha_url http://your-ha:8123
    occ wallboard:config ha_token <long-lived access token>
    occ wallboard:config ha_entities switch.geyser
    occ wallboard:config ha_controls switch.geyser
    occ wallboard:config allow_ha_control true
    

What next

The panel is built around a widget contract, so adding one is a file on the server and a file in the browser. Music and the geyser both went in that way. Next on my list:

Cameras. Frigate already runs on the Pi with ONVIF cameras attached. A still from the front door, refreshed every few seconds, is the most obviously useful addition to a kitchen screen.

Lights. The bridge already covers them, since light is one of the permitted domains, so adding one is two lines of config rather than any more code. The open question is which lights are worth showing on a kitchen screen.

Solar. A Sunsynk inverter runs the house, but its figures do not reach Home Assistant yet: the add-on is installed and only reports its own diagnostics. Once generation and battery state are entities, the geyser card can show them side by side and the panel becomes a way to run the water heater on surplus rather than on a timer.

A device that had been gathering dust in the cupboard for years now gets used every day. Everyone enjoys the photographs, and the music, weather, family calendar and timer are useful. It cost nothing, and it kept a working piece of hardware out of the e-waste pile.