Files
asepharyana 1cdb82a76f Sync config from arch
- hypr/apps.lua
- hypr/autostart.lua
- hypr/envs.lua
- hypr/hyprland.lua
- hypr/hyprsunset.conf
- hypr/input.lua
- hypr/looknfeel.lua
- hypr/omasettings.lua
- hypr/xdph.conf
- omarchy/branding/about.txt
- omarchy/branding/screensaver.txt
- omarchy/extensions/omarchy-menu.jsonc
- omarchy/hooks/battery-low.d/play-warning-sound.sample
- omarchy/hooks/font-set.d/show-font-notification.sample
- omarchy/hooks/post-boot.d/weather.sample
- omarchy/hooks/post-update.d/install-voxtype.hook
- omarchy/hooks/post-update.d/setup-agent.hook
- omarchy/hooks/post-update.d/setup-fingerprint.hook
- omarchy/hooks/post-update.d/show-update-notification.sample
- omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample
- omarchy/hooks/theme-set.d/show-theme-notification.sample
- omarchy/shell.json
- omarchy/shell.toml
- omarchy/theme.name
- omarchy/themes/azure-glow/README.md
- omarchy/themes/azure-glow/alacritty.toml
- omarchy/themes/azure-glow/btop.theme
- omarchy/themes/azure-glow/hyprland.conf
- omarchy/themes/azure-glow/hyprlock.conf
- omarchy/themes/azure-glow/icons.theme
- … 269 more
2026-09-23 15:19:12 +07:00

195 lines
7.9 KiB
Python

"""Who ended the last Wi-Fi association: the access point, this machine,
or nobody (the link fell over).
A BSSID change seen through `iw dev link` is only a fact: we were on one
radio and now we are on another. Whether that was the client's decision,
the access point's, or a link failure is the difference between a laptop
that roams, a router that kicks, and a driver that drops — three problems
with three owners, and the log used to call all of them "Roamed to".
`iw event` is the unprivileged view of nl80211's mlme multicast group.
Every deauthentication and disassociation frame the kernel sends or
receives is reported with its sender, receiver and 802.11 reason code, and
the authentication that follows is reported too. That is enough:
- the frame's sender says who ended it — the AP's address means it kicked
us, our own means this machine did;
- the reason code says why, in the AP's or the driver's own words;
- the delay before the next authentication says whether the client already
knew where it was going. mac80211 emits the local deauth from inside the
call that starts the new authentication when it roams, so a roam's gap is
milliseconds; a lost link is followed by a scan first, and its gap is
seconds.
Only the kernel talks to this module, through `iw`. Lines are read with a
length cap and matched against fixed patterns; two MACs and a reason code
are the only fields kept, and only our own reason texts reach the shell.
"""
import re
import shutil
import subprocess
import threading
import time
from collections import deque
# `iw event -t` line: "<secs>.<usecs>: <ifname> (phy #N): <event...>". The
# timestamp is CLOCK_REALTIME — the clock the daemon stamps with — and it
# is optional so recorded fixtures read the same with or without it.
_PREFIX = r"^(?:(\d+\.\d+): )?\S+(?: \(phy #\d+\))?: "
_MAC = r"([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})"
_ANY_MAC = r"[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}"
# A deauth/disassoc frame: "<sender> -> <receiver> reason N: <text>".
# "unprotected deauth" (a forged frame the kernel refused) does not match:
# the frame word must follow the prefix directly.
RE_FRAME = re.compile(_PREFIX + r"(deauth|disassoc) " + _MAC + " -> " + _MAC
+ r" reason (\d{1,5})\b")
# The first sign of the next association landing.
RE_NEXT = re.compile(_PREFIX + r"(?:(?:auth|assoc) " + _ANY_MAC + " -> "
+ _ANY_MAC + r" status: 0\b|(?:connected|roamed) to "
+ _ANY_MAC + r"\b)")
# 802.11 reason codes, in the words a person would use. Codes 3 and 4 mean
# different things depending on who sent them, so they get a side each
# (a GX gateway's per-station kick arrives as 8, for instance).
REASONS = {
1: "unspecified",
2: "previous authentication no longer valid",
5: "the AP is full",
6: "class 2 frame from an unauthenticated station",
7: "class 3 frame from an unassociated station",
9: "not authenticated",
14: "MIC failure",
15: "4-way handshake timeout",
16: "group key handshake timeout",
17: "IE mismatch in the 4-way handshake",
23: "802.1X authentication failed",
34: "poor channel conditions",
39: "timeout",
}
AP_REASONS = {3: "the AP is leaving", 4: "inactivity", 8: "the AP is leaving the BSS"}
LOCAL_REASONS = {3: "leaving", 4: "beacon loss", 8: "leaving the BSS"}
def reason_text(code, by_ap):
"""'reason 2: previous authentication no longer valid'. Only our own
words, never the tool's: nothing from the wire reaches the shell."""
side = AP_REASONS if by_ap else LOCAL_REASONS
text = side.get(code) or REASONS.get(code)
return "reason %d: %s" % (code, text) if text else "reason %d" % code
class NlEvents(threading.Thread):
"""Runs `iw event` forever, restarting it if it dies, keeping the last
few deauth/disassoc frames and when the next authentication followed.
Like the probes, this never raises into the daemon: without `iw`, or
when the socket is refused, the thread backs off and `cause_for`
answers None — and the link log falls back to plain "Roamed to".
"""
MAX_CAUSES = 64
LINE_CAP = 1024
def __init__(self):
super().__init__(name="nl-events", daemon=True)
self._stop = threading.Event()
self._proc = None
self._lock = threading.Lock()
self._causes = deque(maxlen=self.MAX_CAUSES)
def stop(self):
self._stop.set()
proc = self._proc
if proc and proc.poll() is None:
try:
proc.terminate()
except OSError:
pass
def run(self):
backoff = 1.0
while not self._stop.is_set():
if not shutil.which("iw"):
time.sleep(min(backoff, 30.0))
backoff = min(backoff * 2, 30.0)
continue
try:
self._run_once()
except Exception:
pass # a spawn or parse failure is a retry, not a crash
if not self._stop.is_set():
# `iw event` never exits on its own; if it did, nl80211
# refused us or the tool is broken — do not spin on it.
time.sleep(min(backoff, 30.0))
backoff = min(backoff * 2, 30.0)
def _run_once(self):
self._proc = subprocess.Popen(
["iw", "event", "-t"], stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, errors="replace", bufsize=1,
)
try:
while not self._stop.is_set():
# A capped read: an over-long line comes back in pieces
# that match nothing, instead of growing a buffer.
line = self._proc.stdout.readline(self.LINE_CAP)
if not line:
break
self.consume(line)
finally:
proc, self._proc = self._proc, None
if proc:
try:
proc.terminate()
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
# Would not go quietly: do not leave it running.
try:
proc.kill()
proc.wait(timeout=2)
except (OSError, subprocess.TimeoutExpired):
pass
except OSError:
pass
def consume(self, line: str, now: float = None):
m = RE_FRAME.match(line)
if m:
t = float(m.group(1)) if m.group(1) else (now or time.time())
with self._lock:
self._causes.append({
"t": t, "frame": m.group(2),
"sa": m.group(3).lower(), "da": m.group(4).lower(),
"reason": min(int(m.group(5)), 65535), "next_at": None,
})
return
m = RE_NEXT.match(line)
if m:
t = float(m.group(1)) if m.group(1) else (now or time.time())
with self._lock:
# Only the newest cause is still waiting for its follow-up.
if self._causes and self._causes[-1]["next_at"] is None:
self._causes[-1]["next_at"] = t
def cause_for(self, bssid: str, now: float, window: float):
"""The latest frame within `window` seconds that ended our
association with `bssid`, or None.
by_ap: the AP sent it. gap_s: seconds until the next authentication
was seen, None if none has been yet.
"""
bssid = (bssid or "").lower()
if not bssid:
return None
with self._lock:
for c in reversed(self._causes):
if now - c["t"] > window:
break
if bssid not in (c["sa"], c["da"]):
continue
gap = None if c["next_at"] is None else max(0.0, c["next_at"] - c["t"])
return {"t": c["t"], "frame": c["frame"], "by_ap": c["sa"] == bssid,
"reason": c["reason"], "gap_s": gap}
return None