Skip to content

Commit 0fa3306

Browse files
authored
Detect network applet state changes (#112)
1 parent 7059ffe commit 0fa3306

3 files changed

Lines changed: 235 additions & 240 deletions

File tree

docking/applets/network/applet.py

Lines changed: 97 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import time
1919
from collections.abc import Callable
20+
from dataclasses import replace
2021
from pathlib import Path
2122
from typing import TYPE_CHECKING
2223

@@ -33,6 +34,7 @@
3334
from docking.applets.network.render import create_icon
3435
from docking.applets.network.state import (
3536
AvailableNetwork,
37+
NetworkState,
3638
TrafficCounters,
3739
build_tooltip,
3840
compute_speeds,
@@ -83,15 +85,8 @@ def __init__(self, icon_size: int, config: Config | None = None) -> None:
8385
self._nm_handler_id: int = 0
8486
self._nm_state_handler_id: int = 0
8587

86-
# State
87-
self._is_connected = False
88-
self._is_wifi = False
89-
self._ssid = ""
90-
self._signal_strength = 0
91-
self._iface = ""
92-
self._ip_address = ""
93-
self._rx_speed = 0.0
94-
self._tx_speed = 0.0
88+
# Visible state. Frozen so equality gates re-renders.
89+
self._state = NetworkState()
9590
# "download", "upload", or "none"
9691
self._speed_overlay = "download"
9792

@@ -111,11 +106,11 @@ def create_icon(self, size: int):
111106
"""Load network icon with optional speed overlay."""
112107
return create_icon(
113108
size=size,
114-
is_connected=self._is_connected,
115-
is_wifi=self._is_wifi,
116-
signal_strength=self._signal_strength,
117-
rx_speed=self._rx_speed,
118-
tx_speed=self._tx_speed,
109+
is_connected=self._state.is_connected,
110+
is_wifi=self._state.is_wifi,
111+
signal_strength=self._state.signal_strength,
112+
rx_speed=self._state.rx_speed,
113+
tx_speed=self._state.tx_speed,
119114
speed_overlay=self._speed_overlay,
120115
)
121116

@@ -124,34 +119,35 @@ def refresh_tooltip(self) -> None:
124119

125120
def get_menu_items(self) -> list:
126121
"""Show connection info and common network actions."""
122+
state = self._state
127123
status: list[Gtk.MenuItem] = []
128-
if self._ssid:
124+
if state.ssid:
129125
status.append(
130126
disabled_menu_item(
131127
_("WiFi: {ssid} ({pct}%)").format(
132-
ssid=self._ssid, pct=self._signal_strength
128+
ssid=state.ssid, pct=state.signal_strength
133129
),
134130
gtk=Gtk,
135131
)
136132
)
137-
elif self._is_connected:
133+
elif state.is_connected:
138134
status.append(
139135
disabled_menu_item(
140-
_("Ethernet: {iface}").format(iface=self._iface),
136+
_("Ethernet: {iface}").format(iface=state.iface),
141137
gtk=Gtk,
142138
)
143139
)
144140
else:
145141
status.append(disabled_menu_item(_("Not connected"), gtk=Gtk))
146142

147-
if self._ip_address:
143+
if state.ip_address:
148144
status.append(
149-
disabled_menu_item(_("IP: {ip}").format(ip=self._ip_address), gtk=Gtk)
145+
disabled_menu_item(_("IP: {ip}").format(ip=state.ip_address), gtk=Gtk)
150146
)
151147

152-
if self._is_connected:
153-
down = format_speed(bps=self._rx_speed)
154-
up = format_speed(bps=self._tx_speed)
148+
if state.is_connected:
149+
down = format_speed(bps=state.rx_speed)
150+
up = format_speed(bps=state.tx_speed)
155151
status.append(disabled_menu_item(f"\u2193 {down} \u2191 {up}", gtk=Gtk))
156152

157153
settings: list[Gtk.MenuItem] = []
@@ -246,8 +242,7 @@ def _set_networking_enabled(self, *, enabled: bool) -> None:
246242
exc,
247243
)
248244
return
249-
self._update_nm_state()
250-
self.present()
245+
self._refresh_state()
251246

252247
def _set_wireless_enabled(self, *, enabled: bool) -> None:
253248
if self._nm_client is None:
@@ -261,8 +256,7 @@ def _set_wireless_enabled(self, *, enabled: bool) -> None:
261256
exc,
262257
)
263258
return
264-
self._update_nm_state()
265-
self.present()
259+
self._refresh_state()
266260

267261
def _build_available_networks_submenu(self) -> Gtk.MenuItem:
268262
item = Gtk.MenuItem(label=_("Available Networks"))
@@ -506,7 +500,7 @@ def start(self, notify: Callable[[], None]) -> None:
506500
"notify::state",
507501
self._on_nm_changed,
508502
)
509-
self._update_nm_state()
503+
self._refresh_state()
510504
except GLib.Error:
511505
log.bind(action="connect_nm").warning(
512506
"Could not connect to NetworkManager",
@@ -529,21 +523,21 @@ def stop(self) -> None:
529523
super().stop()
530524

531525
def _on_nm_changed(self, *_args: object) -> None:
532-
"""NM active-connections changed: update state immediately."""
533-
self._update_nm_state()
534-
self.present()
535-
536-
def _update_nm_state(self) -> None:
537-
"""Read current connection info from NetworkManager."""
526+
"""NM active-connections changed: refresh and re-render on change."""
527+
self._refresh_state()
528+
529+
def _refresh_state(self) -> None:
530+
"""Recompute the full visible state and present only on change."""
531+
new_state = self._compute_nm_state()
532+
new_state = self._apply_traffic(state=new_state)
533+
if new_state != self._state:
534+
self._state = new_state
535+
self.present()
536+
537+
def _compute_nm_state(self) -> NetworkState:
538+
"""Return a NetworkState reflecting current NetworkManager status."""
538539
if not self._nm_client:
539-
return
540-
541-
self._is_connected = False
542-
self._is_wifi = False
543-
self._ssid = ""
544-
self._signal_strength = 0
545-
self._iface = ""
546-
self._ip_address = ""
540+
return NetworkState()
547541

548542
# Collect candidates, prioritize wifi > ethernet > other
549543
best_device: NM.Device | None = None
@@ -564,85 +558,109 @@ def _update_nm_state(self) -> None:
564558
best_device = device
565559

566560
if not best_device:
567-
return
561+
return NetworkState()
568562

569-
self._is_connected = True
570-
self._iface = best_device.get_iface() or ""
571-
572-
# IP address
563+
iface = best_device.get_iface() or ""
564+
ip_address = ""
573565
ip4_config = best_device.get_ip4_config()
574566
if ip4_config:
575567
addrs = ip4_config.get_addresses()
576568
if addrs:
577-
self._ip_address = addrs[0].get_address() or ""
569+
ip_address = addrs[0].get_address() or ""
578570

579-
# WiFi specifics
571+
is_wifi = False
572+
ssid = ""
573+
signal_strength = 0
580574
if isinstance(best_device, NM.DeviceWifi):
581-
self._is_wifi = True
575+
is_wifi = True
582576
ap = best_device.get_active_access_point()
583577
if ap:
584578
ssid_bytes = ap.get_ssid()
585579
if ssid_bytes:
586-
self._ssid = ssid_bytes.get_data().decode("utf-8", errors="replace")
587-
self._signal_strength = ap.get_strength()
580+
ssid = ssid_bytes.get_data().decode("utf-8", errors="replace")
581+
signal_strength = ap.get_strength()
582+
583+
return NetworkState(
584+
is_connected=True,
585+
is_wifi=is_wifi,
586+
ssid=ssid,
587+
signal_strength=signal_strength,
588+
iface=iface,
589+
ip_address=ip_address,
590+
# Traffic speeds get filled in by _apply_traffic; keep previous values
591+
# so equality comparison doesn't flap between NM refresh and tick.
592+
rx_speed=self._state.rx_speed,
593+
tx_speed=self._state.tx_speed,
594+
)
588595

589596
def _tick(self) -> bool:
590-
"""Poll traffic counters and wifi signal."""
591-
self._update_nm_state()
592-
self._update_traffic()
593-
self._update_wifi_signal()
594-
self.present()
597+
"""Poll traffic counters and wifi signal, re-render only on change."""
598+
self._refresh_state()
595599
return True
596600

597-
def _update_traffic(self) -> None:
598-
"""Read /proc/net/dev and compute speeds for active interface."""
599-
if not self._iface:
600-
self._rx_speed = 0.0
601-
self._tx_speed = 0.0
602-
return
601+
def _apply_traffic(self, *, state: NetworkState) -> NetworkState:
602+
"""Return ``state`` with rx/tx speeds updated from /proc/net/dev."""
603+
if not state.iface:
604+
self._prev_counters = None
605+
return replace(state, rx_speed=0.0, tx_speed=0.0)
603606
try:
604607
with _PROC_NET_DEV.open() as f:
605608
counters = parse_proc_net_dev(text=f.read())
606609
except OSError as exc:
607610
log.debug("Failed to read %s: %s", _PROC_NET_DEV, exc)
608-
return
611+
return state
609612

610613
now = time.monotonic()
611-
current = counters.get(self._iface)
614+
current = counters.get(state.iface)
615+
rx_speed = state.rx_speed
616+
tx_speed = state.tx_speed
612617
if current and self._prev_counters:
613618
elapsed = now - self._prev_time
614-
self._rx_speed, self._tx_speed = compute_speeds(
619+
rx_speed, tx_speed = compute_speeds(
615620
prev=self._prev_counters,
616621
curr=current,
617622
elapsed_s=elapsed,
618623
)
619624
if current:
620625
self._prev_counters = current
621626
self._prev_time = now
627+
# NM may have stale signal strength; refresh while we have the client.
628+
signal = self._refresh_wifi_signal(state=state)
629+
return NetworkState(
630+
is_connected=state.is_connected,
631+
is_wifi=state.is_wifi,
632+
ssid=state.ssid,
633+
signal_strength=signal,
634+
iface=state.iface,
635+
ip_address=state.ip_address,
636+
rx_speed=rx_speed,
637+
tx_speed=tx_speed,
638+
)
622639

623-
def _update_wifi_signal(self) -> None:
624-
"""Re-read wifi signal from NM (access point strength can change)."""
625-
if not self._nm_client or not self._is_wifi:
626-
return
640+
def _refresh_wifi_signal(self, *, state: NetworkState) -> int:
641+
"""Return the current wifi signal strength, or ``state.signal_strength``."""
642+
if not self._nm_client or not state.is_wifi:
643+
return state.signal_strength
627644
for conn in self._nm_client.get_active_connections():
628645
if conn.get_state() != NM.ActiveConnectionState.ACTIVATED:
629646
continue
630647
for device in conn.get_devices() or ():
631648
if isinstance(device, NM.DeviceWifi):
632649
ap = device.get_active_access_point()
633650
if ap:
634-
self._signal_strength = ap.get_strength()
635-
return
651+
return ap.get_strength()
652+
return state.signal_strength
653+
return state.signal_strength
636654

637655
def _build_tooltip(self) -> str:
638656
return build_tooltip(
639-
is_connected=self._is_connected,
640-
ssid=self._ssid,
641-
signal_strength=self._signal_strength,
642-
iface=self._iface,
643-
ip_address=self._ip_address,
644-
rx_speed=self._rx_speed,
645-
tx_speed=self._tx_speed,
657+
is_connected=self._state.is_connected,
658+
ssid=self._state.ssid,
659+
signal_strength=self._state.signal_strength,
660+
iface=self._state.iface,
661+
ip_address=self._state.ip_address,
662+
rx_speed=self._state.rx_speed,
663+
tx_speed=self._state.tx_speed,
646664
)
647665

648666
def _has_wifi_device(self) -> bool:

docking/applets/network/state.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import shutil
1919
import subprocess
20+
from dataclasses import dataclass
2021
from typing import NamedTuple
2122

2223
from docking.applets.tooltip import structured_tooltip
@@ -51,6 +52,24 @@ class AvailableNetwork(NamedTuple):
5152
is_active: bool
5253

5354

55+
@dataclass(frozen=True)
56+
class NetworkState:
57+
"""Visible state of the Network applet.
58+
59+
Frozen so equality comparison gates re-rendering: the applet only calls
60+
``present()`` when this value actually changes between polls.
61+
"""
62+
63+
is_connected: bool = False
64+
is_wifi: bool = False
65+
ssid: str = ""
66+
signal_strength: int = 0
67+
iface: str = ""
68+
ip_address: str = ""
69+
rx_speed: float = 0.0
70+
tx_speed: float = 0.0
71+
72+
5473
_CONNECTION_INFO_COMMANDS: tuple[tuple[str, ...], ...] = (
5574
("gnome-control-center", "wifi"),
5675
("gnome-control-center", "network"),

0 commit comments

Comments
 (0)