Skip to content

feat: receive remote-monitoring cook state over FCM - #260

Open
FezVrasta wants to merge 1 commit into
miaucl:masterfrom
FezVrasta:remote-monitoring-push
Open

FezVrasta wants to merge 1 commit into
miaucl:masterfrom
FezVrasta:remote-monitoring-push

Conversation

@FezVrasta

Copy link
Copy Markdown
Contributor

Summary

#251 added the remote-monitoring REST surface and cooking_activity_from_push(), but deliberately left receiving the messages to the caller. In practice that means every consumer has to reimplement FCM registration and re-derive the Cookidoo app's Firebase identifiers from the APK before it can see a single cook state — there is no endpoint to poll, so without this there is no way to use the feature at all.

This adds CookidooRemoteMonitoring, which closes the loop:

  • obtains an FCM registration token without an Android device (via firebase-messaging)
  • registers it with register_push_token()
  • decodes each incoming data message with cooking_activity_from_push() and hands a typed CookidooCookingActivity to a callback
  • stop() unregisters the token and shuts the client down
monitoring = CookidooRemoteMonitoring(
    cookidoo,
    lambda activity: print(activity.state, activity.recipe_name),
    mobile_app_id="a-stable-per-installation-uuid",
    credentials=stored_credentials,
    on_credentials=save_credentials,
)
await monitoring.start()

Details worth a look

  • Credential persistence. FCM credentials can be passed in and are reported back when they rotate. Reusing them keeps the same push token across restarts rather than leaving a new registration behind on every start.
  • Token rotation. When Firebase rotates the token, the old registration stops receiving anything. The credentials callback detects the change and re-registers on a tracked task, which stop() cancels.
  • Payload shapes. The appliance flattens the cook fields into the data message, but the app's PushNotificationService also reads them from cookingActivity / remoteMonitoringInfo, as an object or a JSON string. All three are accepted.
  • Firebase identifiers (FCM_PROJECT_ID, FCM_APP_ID, FCM_API_KEY, FCM_SENDER_ID) are the app's public client identifiers from its google-services resources — the same class of value as the OAuth2 client id, not credentials.

Why in the library rather than in the consumer

This started as integration-side code for the Home Assistant PR, per the note in #251. Moving it here follows the ring-doorbell precedent — it owns firebase-messaging and its Firebase ids, and the ring integration just consumes events — and matches Home Assistant's rule that protocol logic belongs in the library, not the integration.

firebase-messaging~=0.4 is a new runtime dependency (same package and major that ring-doorbell uses).

Verification

  • pytest: 380 passed, coverage 100% maintained (remote_monitoring.py 99 stmts / 24 branches, fully covered)
  • ruff check / ruff format --check: clean on the added files
  • mypy: clean (16 source files)

Two files on master (cookidoo_api/helpers.py, tests/responses.py) are reformatted by ruff 0.16.6 — pre-existing drift, deliberately left untouched here.

Follow-up

Unblocks the Home Assistant PR that surfaces paired appliances as devices with live cook-state entities.

Remote monitoring has no endpoint that returns the current cook state --
the appliance pushes it to the mobile app as a Firebase Cloud Messaging
data message. miaucl#251 added the REST surface and the payload decoder but
left receiving the messages to the caller, which meant every consumer
had to reimplement FCM registration and re-derive the app's Firebase
identifiers.

`CookidooRemoteMonitoring` closes that gap: it obtains an FCM
registration token without an Android device, registers it with
`register_push_token`, and decodes each incoming message with
`cooking_activity_from_push` before handing it to a callback. FCM
credentials can be persisted and restored to keep the same push token
across restarts, and a rotated token is re-registered automatically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9tJdQdLB7bcvqpRp16LXC
@github-actions github-actions Bot added 📖 documentation Improvements or additions to documentation ♻️ dependencies Pull requests that update a dependency file 🧪 testing Pull request that adds tests labels Sep 5, 2026
@codecov

codecov Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (b564d48) to head (bd0376a).

Additional details and impacted files
@@            Coverage Diff             @@
##            master      #260    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files            8         9     +1     
  Lines         1254      1359   +105     
  Branches       113       125    +12     
==========================================
+ Hits          1254      1359   +105     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread cookidoo_api/const.py
@miaucl

miaucl commented Sep 6, 2026

Copy link
Copy Markdown
Owner

It would be great to have some sort of setup to test firebase integration systematically. The smoke tests won't do it, as it is push and not pull, so no api to query. Do you have an idea?

@miaucl
miaucl self-requested a review September 6, 2026 18:01

@miaucl miaucl left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good in general, small bug found:

Bug found —  stop()  crashes if  start()  fails partway:

 start()  assigns  self._client  before awaiting  checkin_or_register() . If that (or the subsequent  .start() ) raises — e.g. registration with Google's backend fails —  self._client  is already set, but the underlying  FcmPushClient  was never actually started, so its internal  stopping_lock  stays  None . A caller doing the natural cleanup:

try:
    await monitoring.start()
except Exception:
    await monitoring.stop()

gets a second exception ( TypeError: 'NoneType' object does not support the asynchronous context manager protocol ) masking the original failure. Reproduced deterministically with mocks:

start() failed as expected: registration failed
stop() ALSO FAILED: TypeError 'NoneType' object does not support the asynchronous context manager protocol

Fix: only assign  self._client  (or guard  stop() ) after  checkin_or_register() / .start()  succeed, e.g. track a local variable and assign  self._client  last, or wrap the  self._client.stop()  call in  stop()  with a try/except like the unregister call already does.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📖 documentation Improvements or additions to documentation ♻️ dependencies Pull requests that update a dependency file 🧪 testing Pull request that adds tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants