Writing — Will Curran

Three Answers I Found in the Home Assistant iOS App's Source

Written by Will Curran | Sep 8, 2026, 4:00:00 PM

Over three days in September 2026 I hit three small problems with the Home Assistant app on my iPhone, and each one looked like a bug. A progress notification buzzed my phone and my watch on every update. A notification tap always threw me out to Safari. A Focus filter offered a picker with nothing in it. All three answers were in public source code. Two were design choices with a code comment or a pull request explaining them, and the third already had a maintainer's answer on a closed issue.

The answers came down to one missing key, one hard-coded list of four paths, and one deliberate TestFlight gate.

The progress card that buzzed on every update

Home Assistant can drive an iOS Live Activity: the card on the Lock Screen that shows something in progress, with a bar that fills as the job moves along. I built one for a robot vacuum. Every update buzzed the phone and the watch, and a single run sent about a hundred updates.

My first fix was a quieter interruption level, push: interruption-level: passive. It changed nothing. My second fix was to send fewer updates. That took a run from about a hundred pushes down to six, and it threw away the detail I had built the card to show. Neither was the fix.

The answer lived one layer up from the app, in Home Assistant's open-source push relay. Its Live Activity builder attaches a full alert, title and body, to every update unless the payload says silent: true. With that key, the alert shrinks to an empty title and the push goes out at a lower APNs priority. The same builder never reads the push: block at all, which is why my interruption level went nowhere. On the app's local-push path, the iOS code strips the body and the sound when silent is true.

The key goes next to live_update, inside the inner data: block. A generic example:

action: notify.mobile_app_your_phone
data:
  title: "Dishwasher"
  message: "Drying"
  data:
    tag: dishwasher_progress
    live_update: true
    silent: true
    progress: 70
    progress_max: 100

A Live Activity still alerts when it starts and when it ends, so two buzzes per run are the floor. And a maintainer had already given this exact answer on a closed issue in July. At the time, one sentence in the docs said updates arrive silently, while the docs' own field table described silent correctly. I had built on the sentence.

I added silent: true to the updates and put the detail back into the card. My notes record that change, not a count of buzzes afterwards, so I will leave it at what the code says.

A tap that always left the app

I had a notification with a short video clip attached. Tapping it opened the clip in a bare browser player in Safari, not inside the Home Assistant app. It looked like I had the URL wrong.

I didn't. The app's web view keeps a hard-coded list of four path prefixes it will not show itself: /api, /static, /hacsfiles and /local. Any tap target that starts with one of them goes to the browser. The comment above the list explains why: those paths aren't frontend pages, and a user who lands on one inside the app can get stuck.

let ignoredPaths = [
    "/api",
    "/static",
    "/hacsfiles",
    "/local",
]

That rules out more than my URL. Anything served from the www folder lives under /local, so a wrapper page with a nicer player leaves the app too. A signed /api link has the same problem. The way through is a relative dashboard path: it stays in the app, and the code that builds the in-app URL keeps the query string, so you can tap into a dashboard view and let a card there play the clip. As of October 2026, the companion docs list relative URLs as a tap target but don't mention the four exceptions.

A Focus filter with nothing to pick

In iOS Settings, under Focus, Home Assistant showed up as a Focus filter. Its picker said "No options available". My first guess was an exposure setting on the Home Assistant side. I changed it, nothing happened, and I changed it back.

The picker never asks the server. It is filled from a small database on the phone, holding Focus names you type into the app yourself. In the app version I was running, 2026.9.0 from the App Store, the screen where you type those names was gated to TestFlight builds. A pull request in August 2026 limited the feature to TestFlight while it matured. The filter itself still showed up in iOS Settings, so on my App Store build it had an empty picker.

guard Current.isTestFlight else {
    return .init(error: FocusNameError.unavailable)
}

Even on TestFlight, 2026.9.0 had a second catch. The sensor hid itself until at least one name existed, and its own settings row was the only way to create the first name. A fix for that merged on September 2, after 2026.9.0 was tagged.

This one did look like a rough edge worth reporting. I searched the issue tracker for the picker's message and found no report.

The parts that were not magic

I made two wrong fixes and one wrong guess before I read the code.

Reading code shows what the code does. What the phone does with the result is Apple's part. The silent update depends on how Apple treats an alert with an empty title. The watch confirmations I found came from other users on a closed issue, not from my own testing.

Much of the reading was done with an AI assistant, and each answer came back tied to a file in a public repository.

If you are about to try this

  • Match the source to the version on your phone. Find the release tag for your build, then read the pull requests merged after it. One Focus fix had already merged, just after my version was tagged.
  • Follow the message through every layer. The silent answer was in the push relay, not the app.
  • When a setting does nothing, ask whether anything reads it. My interruption level was never parsed on that path.
  • Grep for the words on the screen. A visible string leads you to its key in the strings file, and the key leads you to the code.
  • Search closed issues for the symptom. A maintainer may have answered it already.

Build it yourself

What it does: answers "is this a bug or is it intended?" for an open-source app before you file anything. The parts:

  • A shallow clone of the app's repository, plus any server or relay repositories the feature passes through.
  • A fast text search, such as ripgrep, and the GitHub CLI for issues, pull requests and release tags.
  • Your installed version, read from the app's About screen.

How the pieces fit: start from the string you see on screen, find where it is used, follow the call to the place that decides the behaviour, then check that code at your release tag and at the main branch. Read the pull requests in between, and search issues with the exact wording. It costs nothing but time.

Give this to your AII am seeing [describe the behaviour] in [open-source app] version [version] on [platform]. Before I file a bug, help me find out from the source whether it is intended. Clone the public repository shallowly, plus any server or relay repository the feature passes through. Start from the exact text I see on screen, find its key in the strings files, and follow it to the code that decides the behaviour. For every claim, give me the file, the line and the commit or release tag it came from, and check the release tag that matches my version as well as the main branch. List the pull requests merged after my version that touch this code. Search open and closed issues for the symptom and quote only what maintainers actually wrote. Tell me whether this is a bug, intended behaviour, or already fixed in a later version, and if it is a bug, draft a short report that points at the exact lines.

Line references here are from the iOS app's main branch on October 22, 2026 (commit d87b0f5) and the push relay's main branch (commit 7220046). The Focus gate is also in release 2026.10.1. If you have found an answer like this in an app's source, I would like to hear about it.