Skip to content

Redesign websocket#401

Draft
MartinHjelmare wants to merge 15 commits intoDanielhiversen:masterfrom
MartinHjelmare:redesign
Draft

Redesign websocket#401
MartinHjelmare wants to merge 15 commits intoDanielhiversen:masterfrom
MartinHjelmare:redesign

Conversation

@MartinHjelmare
Copy link
Copy Markdown
Contributor

@MartinHjelmare MartinHjelmare commented Apr 3, 2026

  • Refactor the websocket connection in the realtime module.
  • Separate the TibberHome feature from the realtime module and make the realtime module unaware of TibberHome. The TibberHome is aware of the realtime feature but not the other way around. The realtime module should focus on the websocket connection and not handle specifics of the Tibber data features.
  • Remove the custom watchdog and replace it with the builtin keep alive features of the gql websocket connection.
    keep_alive_timeout=90,
    ping_interval=30,
    pong_timeout=20,
    If there's a keep alive / ping-pong timeout the websocket connection will be closed.
  • Use the reconnecting feature of the gql client to automatically reconnect in the background when the connection is closed.
  • Configure reconnect back-off and jitter via the tenacity library according to the Tibber developer docs recommendations. The only thing we don't follow yet is to handle an authentication failure specifically. This can be added later. Note that we don't currently on the master branch handle an authentication failure specifically in the websocket connection, so there's no regression in that sense.
  • Make sure we still reconnect the websocket connection after a token refresh.
  • Add tests of the changed code.
  • I've made some additional changes like making a base exception for the library that all exceptions inherit and added some debug logging that helped me when I've been testing my changes.

This is a big PR. Let me know what I can do to help with the review.

Comment thread tibber/home.py
self,
callback: Callable[..., Any],
*,
on_error: Callable[[Exception], None] | None = None,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The on_error callback will allow users of the library to react on errors with the subscription, like changing the state of instances that rely on the subscription when there's an error.

Comment thread tibber/home.py
and live_data.get("power") is None
):
live_data["power"] = 0
def _add_extra_data(self, data: dict[str, Any]) -> dict[str, Any]:
Copy link
Copy Markdown
Contributor Author

@MartinHjelmare MartinHjelmare Apr 3, 2026

Choose a reason for hiding this comment

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

I've broken out the extra data addition to a helper method to keep things better separated. I've not changed how or what extra data is added.

I recommend to review the PR with whitespace changes hidden, to make it clear that this part wasn't changed.

Comment thread tibber/realtime.py
Comment on lines +19 to +24
KEEP_ALIVE_TIMEOUT = 90
LOCK_CONNECT = asyncio.Lock()
MIN_RECONNECT_INTERVAL = 1
MAX_RECONNECT_INTERVAL = 60
PING_INTERVAL = 30
PONG_TIMEOUT = 20
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Here are the keep alive and reconnect settings.

Comment thread tibber/realtime.py
raise WebsocketReconnectedError("Websocket reconnected") from err
raise WebsocketTransportError(err) from err

async def set_subscription_endpoint(self, url: str) -> None:
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We now reconnect if the subscription endpoint changes. This wasn't done before. That's why I've changed to a coroutine function for setting the endpoint.

Comment thread test/test_realtime.py
with pytest.raises(WebsocketReconnectedError):
await anext(tibber_rt.subscribe(MagicMock(), on_error=None))

await unblock_task
@Danielhiversen
Copy link
Copy Markdown
Owner

Remove the custom watchdog and replace it with the builtin keep alive features of the gql websocket connection.

That will only reconnect if we dont get an answer to the ping?
That has not always been enough. It can happen that websocket connection is alive, but we dont receive any data from the Tibber pulse.

@MartinHjelmare
Copy link
Copy Markdown
Contributor Author

Ok, I didn't know that. Is it enough to resubscribe the home if the Pulse is not responding?

I think we can have a simple timer task per home that checks if it's more than 60 seconds since the last message, and resubscribes in that case, otherwise sleeps another 60 seconds.

We use a single websocket connection that can have multiple home subscriptions running at the same time.

@Danielhiversen
Copy link
Copy Markdown
Owner

I think that should be fine

Comment thread tibber/home.py
) -> None:
"""Subscribe to Tibber."""
try:
async for _data in self._tibber_control.realtime.subscribe(
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.

Suggested change
async for _data in self._tibber_control.realtime.subscribe(
self._last_rt_data_received = time.time()
async for _data in self._tibber_control.realtime.subscribe(

Otherwise, I think we will never reconnect if we dont get the first data?
(Then we should rename _last_rt_data_received to something like _rt_last_activity ?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think we can adjust the None check in _rt_subscription_timeout instead to achieve the same thing. 👍

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment thread tibber/home.py
if not (data := await self._tibber_control.execute(REAL_TIME_CONSUMPTION_ENABLED % self._home_id)):
_LOGGER.error("Could not get the data.")
return
self.info["viewer"]["home"]["features"]["realTimeConsumptionEnabled"] = data["viewer"]["home"]["features"][
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is fragile as it needs the info attribute to already been set with the data structure. I need to improve this.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants