Extension GNOME Shell WiFi Signal Plus : affiche la génération WiFi (4/5/6/7) dans la barre avec infos détaillées au survol
- Détection WiFi 4 (HT), 5 (VHT), 6 (HE), 7 (EHT) via parsing iw - Infos NetworkManager : SSID, signal, débit, sécurité, bande/canal - Popup avec sections : connexion, performance, signal/sécurité - Couleurs par génération dans la barre (gris/bleu/vert/violet) - 23 tests unitaires pour le parsing iw et la détection de génération - Environnement Nix avec flake.nix, TypeScript, ESLint, Vitest
This commit is contained in:
commit
ddb6b4ecfb
18 changed files with 5299 additions and 0 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
use flake
|
||||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
.direnv/
|
||||||
|
result
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
}
|
||||||
24
eslint.config.js
Normal file
24
eslint.config.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.strict,
|
||||||
|
...tseslint.configs.stylistic,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ['dist/', 'node_modules/', '*.config.js'],
|
||||||
|
}
|
||||||
|
);
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1769461804,
|
||||||
|
"narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
48
flake.nix
Normal file
48
flake.nix
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
description = "WiFi Signal Plus - GNOME Shell Extension";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
# Node.js
|
||||||
|
nodejs_22
|
||||||
|
nodePackages.npm
|
||||||
|
|
||||||
|
# Pour l'extension GNOME
|
||||||
|
glib
|
||||||
|
gobject-introspection
|
||||||
|
gnome-shell
|
||||||
|
|
||||||
|
# Outils WiFi
|
||||||
|
iw
|
||||||
|
wirelesstools
|
||||||
|
|
||||||
|
# Outils de développement
|
||||||
|
gnome-extensions-cli
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
echo "🛜 WiFi Signal Plus - Dev Environment"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " npm install - Install dependencies"
|
||||||
|
echo " npm run build - Build extension"
|
||||||
|
echo " npm run install-extension - Install to GNOME"
|
||||||
|
echo " npm run lint - Run ESLint"
|
||||||
|
echo " npm run test - Run tests"
|
||||||
|
echo ""
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
8
metadata.json
Normal file
8
metadata.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"uuid": "wifi-signal-plus@music-music.music",
|
||||||
|
"name": "WiFi Signal Plus",
|
||||||
|
"description": "Displays WiFi generation (4/5/6/7) in the top bar with detailed connection info on hover",
|
||||||
|
"shell-version": ["45", "46", "47", "48", "49"],
|
||||||
|
"version": 1,
|
||||||
|
"url": "https://github.com/music-music/wifi-signal-plus"
|
||||||
|
}
|
||||||
3669
package-lock.json
generated
Normal file
3669
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
34
package.json
Normal file
34
package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"name": "wifi-signal-plus",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "GNOME Shell extension displaying WiFi generation (4/5/6/7) with detailed info",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && npm run copy-assets",
|
||||||
|
"copy-assets": "cp metadata.json stylesheet.css dist/",
|
||||||
|
"install-extension": "npm run build && rm -rf ~/.local/share/gnome-shell/extensions/wifi-signal-plus@music-music.music && cp -r dist ~/.local/share/gnome-shell/extensions/wifi-signal-plus@music-music.music",
|
||||||
|
"nested": "npm run install-extension && MUTTER_DEBUG_DUMMY_MODE_SPECS=1920x1080 dbus-run-session gnome-shell --devkit --wayland",
|
||||||
|
"watch": "tsc --watch",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"lint:fix": "eslint src/ --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.ts\"",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@girs/gjs": "4.0.0-beta.38",
|
||||||
|
"@girs/gnome-shell": "^49.1.0",
|
||||||
|
"@girs/nm-1.0": "1.49.4-4.0.0-beta.38",
|
||||||
|
"@girs/glib-2.0": "2.86.0-4.0.0-beta.38",
|
||||||
|
"@girs/gio-2.0": "2.86.0-4.0.0-beta.38",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"eslint": "^9.19.0",
|
||||||
|
"@eslint/js": "^9.19.0",
|
||||||
|
"typescript-eslint": "^8.22.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"vitest": "^3.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/ambient.d.ts
vendored
Normal file
4
src/ambient.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/// <reference types="@girs/gjs" />
|
||||||
|
/// <reference types="@girs/gjs/dom" />
|
||||||
|
/// <reference types="@girs/gnome-shell/ambient" />
|
||||||
|
/// <reference types="@girs/gnome-shell/extensions/global" />
|
||||||
269
src/extension.ts
Normal file
269
src/extension.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
/**
|
||||||
|
* WiFi Signal Plus - GNOME Shell Extension
|
||||||
|
*
|
||||||
|
* Displays WiFi generation (4/5/6/7) in the top bar with detailed info on hover.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Clutter from 'gi://Clutter';
|
||||||
|
import GLib from 'gi://GLib';
|
||||||
|
import St from 'gi://St';
|
||||||
|
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
|
||||||
|
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||||
|
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
||||||
|
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
WifiInfoService,
|
||||||
|
getSignalQuality,
|
||||||
|
isConnected,
|
||||||
|
type WifiConnectionInfo,
|
||||||
|
type ConnectedInfo,
|
||||||
|
} from './wifiInfo.js';
|
||||||
|
import {
|
||||||
|
WIFI_GENERATIONS,
|
||||||
|
GENERATION_CSS_CLASSES,
|
||||||
|
getGenerationLabel,
|
||||||
|
getGenerationDescription,
|
||||||
|
} from './wifiGeneration.js';
|
||||||
|
import type { GenerationCssClass, ChannelWidthMHz, SignalDbm } from './types.js';
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL_SECONDS = 5;
|
||||||
|
const PLACEHOLDER = '--' as const;
|
||||||
|
|
||||||
|
type MenuItemId =
|
||||||
|
| 'ssid'
|
||||||
|
| 'generation'
|
||||||
|
| 'band'
|
||||||
|
| 'bitrate'
|
||||||
|
| 'channelWidth'
|
||||||
|
| 'mcs'
|
||||||
|
| 'signal'
|
||||||
|
| 'security'
|
||||||
|
| 'bssid';
|
||||||
|
|
||||||
|
interface MenuItemConfig {
|
||||||
|
readonly id: MenuItemId;
|
||||||
|
readonly label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_STRUCTURE: readonly MenuItemConfig[][] = [
|
||||||
|
// Section: Connection
|
||||||
|
[
|
||||||
|
{ id: 'ssid', label: 'Network' },
|
||||||
|
{ id: 'generation', label: 'Generation' },
|
||||||
|
{ id: 'band', label: 'Band' },
|
||||||
|
],
|
||||||
|
// Section: Performance
|
||||||
|
[
|
||||||
|
{ id: 'bitrate', label: 'Speed' },
|
||||||
|
{ id: 'channelWidth', label: 'Width' },
|
||||||
|
{ id: 'mcs', label: 'Modulation' },
|
||||||
|
],
|
||||||
|
// Section: Signal & Security
|
||||||
|
[
|
||||||
|
{ id: 'signal', label: 'Signal' },
|
||||||
|
{ id: 'security', label: 'Security' },
|
||||||
|
{ id: 'bssid', label: 'BSSID' },
|
||||||
|
],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default class WifiSignalPlusExtension extends Extension {
|
||||||
|
private indicator: PanelMenu.Button | null = null;
|
||||||
|
private label: St.Label | null = null;
|
||||||
|
private wifiService: WifiInfoService | null = null;
|
||||||
|
private refreshTimeout: number | null = null;
|
||||||
|
private readonly menuItems = new Map<MenuItemId, PopupMenu.PopupMenuItem>();
|
||||||
|
|
||||||
|
enable(): void {
|
||||||
|
this.wifiService = new WifiInfoService();
|
||||||
|
this.wifiService
|
||||||
|
.init()
|
||||||
|
.then(() => {
|
||||||
|
this.createIndicator();
|
||||||
|
this.refresh();
|
||||||
|
this.startRefreshTimer();
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
console.error('[WiFi Signal Plus] Failed to initialize:', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disable(): void {
|
||||||
|
this.stopRefreshTimer();
|
||||||
|
this.indicator?.destroy();
|
||||||
|
this.wifiService?.destroy();
|
||||||
|
|
||||||
|
this.indicator = null;
|
||||||
|
this.wifiService = null;
|
||||||
|
this.label = null;
|
||||||
|
this.menuItems.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createIndicator(): void {
|
||||||
|
this.indicator = new PanelMenu.Button(0.0, this.metadata.name, false);
|
||||||
|
this.indicator.add_style_class_name('wifi-signal-plus-indicator');
|
||||||
|
|
||||||
|
this.label = new St.Label({
|
||||||
|
text: 'WiFi',
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
style_class: 'wifi-signal-plus-label',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.indicator.add_child(this.label);
|
||||||
|
this.buildMenu();
|
||||||
|
|
||||||
|
Main.panel.addToStatusArea(this.uuid, this.indicator);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMenu(): void {
|
||||||
|
if (!this.indicator) return;
|
||||||
|
|
||||||
|
const menu = this.indicator.menu as PopupMenu.PopupMenu;
|
||||||
|
menu.box.add_style_class_name('wifi-signal-plus-popup');
|
||||||
|
|
||||||
|
MENU_STRUCTURE.forEach((section, index) => {
|
||||||
|
for (const { id, label } of section) {
|
||||||
|
this.addMenuItem(menu, id, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add separator between sections (not after last)
|
||||||
|
if (index < MENU_STRUCTURE.length - 1) {
|
||||||
|
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private addMenuItem(menu: PopupMenu.PopupMenu, id: MenuItemId, label: string): void {
|
||||||
|
const item = new PopupMenu.PopupMenuItem(`${label}: ${PLACEHOLDER}`, { reactive: false });
|
||||||
|
menu.addMenuItem(item);
|
||||||
|
this.menuItems.set(id, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateMenuItem(id: MenuItemId, label: string, value: string): void {
|
||||||
|
const item = this.menuItems.get(id);
|
||||||
|
item?.label.set_text(`${label}: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private refresh(): void {
|
||||||
|
if (!this.wifiService || !this.label) return;
|
||||||
|
|
||||||
|
const info = this.wifiService.getConnectionInfo();
|
||||||
|
this.updateIndicatorLabel(info);
|
||||||
|
this.updateMenuContent(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateIndicatorLabel(info: WifiConnectionInfo): void {
|
||||||
|
if (!this.label) return;
|
||||||
|
|
||||||
|
this.clearGenerationStyles();
|
||||||
|
|
||||||
|
if (!isConnected(info)) {
|
||||||
|
this.label.set_text('WiFi --');
|
||||||
|
this.label.add_style_class_name(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.UNKNOWN]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.label.set_text(getGenerationLabel(info.generation));
|
||||||
|
this.label.add_style_class_name(GENERATION_CSS_CLASSES[info.generation]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearGenerationStyles(): void {
|
||||||
|
if (!this.label) return;
|
||||||
|
|
||||||
|
const cssClasses = Object.values(GENERATION_CSS_CLASSES) as GenerationCssClass[];
|
||||||
|
for (const cssClass of cssClasses) {
|
||||||
|
this.label.remove_style_class_name(cssClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateMenuContent(info: WifiConnectionInfo): void {
|
||||||
|
if (!isConnected(info)) {
|
||||||
|
this.showDisconnectedState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showConnectedState(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private showDisconnectedState(): void {
|
||||||
|
for (const section of MENU_STRUCTURE) {
|
||||||
|
for (const { id, label } of section) {
|
||||||
|
const value = id === 'ssid' ? 'Not connected' : PLACEHOLDER;
|
||||||
|
this.updateMenuItem(id, label, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private showConnectedState(info: ConnectedInfo): void {
|
||||||
|
this.updateMenuItem('ssid', 'Network', info.ssid);
|
||||||
|
this.updateMenuItem('generation', 'Generation', getGenerationDescription(info.generation));
|
||||||
|
this.updateMenuItem('band', 'Band', this.formatBand(info));
|
||||||
|
this.updateMenuItem('bitrate', 'Speed', this.formatBitrate(info));
|
||||||
|
this.updateMenuItem('channelWidth', 'Width', this.formatChannelWidth(info.channelWidth));
|
||||||
|
this.updateMenuItem('mcs', 'Modulation', this.formatModulation(info));
|
||||||
|
this.updateMenuItem('signal', 'Signal', this.formatSignal(info.signalStrength));
|
||||||
|
this.updateMenuItem('security', 'Security', info.security);
|
||||||
|
this.updateMenuItem('bssid', 'BSSID', info.bssid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatBand(info: ConnectedInfo): string {
|
||||||
|
return `${info.band} · Ch ${info.channel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatBitrate(info: ConnectedInfo): string {
|
||||||
|
const { txBitrate, rxBitrate, bitrate } = info;
|
||||||
|
|
||||||
|
if (txBitrate !== null && rxBitrate !== null) {
|
||||||
|
const tx = txBitrate as number;
|
||||||
|
const rx = rxBitrate as number;
|
||||||
|
return tx === rx ? `${tx} Mbit/s` : `↑${tx} ↓${rx} Mbit/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (txBitrate !== null) return `↑${txBitrate} Mbit/s`;
|
||||||
|
if (rxBitrate !== null) return `↓${rxBitrate} Mbit/s`;
|
||||||
|
return `${bitrate} Mbit/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatChannelWidth(width: ChannelWidthMHz | null): string {
|
||||||
|
return width !== null ? `${width} MHz` : PLACEHOLDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatModulation(info: ConnectedInfo): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (info.mcs !== null) {
|
||||||
|
parts.push(`MCS ${info.mcs}`);
|
||||||
|
}
|
||||||
|
if (info.nss !== null) {
|
||||||
|
parts.push(`${info.nss}×${info.nss} MIMO`);
|
||||||
|
}
|
||||||
|
if (info.guardInterval !== null) {
|
||||||
|
parts.push(`GI ${info.guardInterval}µs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(' · ') : PLACEHOLDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatSignal(signalStrength: SignalDbm): string {
|
||||||
|
const quality = getSignalQuality(signalStrength);
|
||||||
|
return `${signalStrength} dBm (${quality})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startRefreshTimer(): void {
|
||||||
|
this.refreshTimeout = GLib.timeout_add_seconds(
|
||||||
|
GLib.PRIORITY_DEFAULT,
|
||||||
|
REFRESH_INTERVAL_SECONDS,
|
||||||
|
() => {
|
||||||
|
this.refresh();
|
||||||
|
return GLib.SOURCE_CONTINUE;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopRefreshTimer(): void {
|
||||||
|
if (this.refreshTimeout !== null) {
|
||||||
|
GLib.source_remove(this.refreshTimeout);
|
||||||
|
this.refreshTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/types.ts
Normal file
173
src/types.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
/**
|
||||||
|
* Core type definitions for WiFi Signal Plus
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare const brand: unique symbol;
|
||||||
|
type Brand<T, B> = T & { readonly [brand]: B };
|
||||||
|
|
||||||
|
export type FrequencyMHz = Brand<number, 'FrequencyMHz'>;
|
||||||
|
export type SignalDbm = Brand<number, 'SignalDbm'>;
|
||||||
|
export type SignalPercent = Brand<number, 'SignalPercent'>;
|
||||||
|
export type BitrateMbps = Brand<number, 'BitrateMbps'>;
|
||||||
|
export type ChannelWidthMHz = Brand<number, 'ChannelWidthMHz'>;
|
||||||
|
export type ChannelNumber = Brand<number, 'ChannelNumber'>;
|
||||||
|
export type McsIndex = Brand<number, 'McsIndex'>;
|
||||||
|
export type SpatialStreams = Brand<number, 'SpatialStreams'>;
|
||||||
|
export type GuardIntervalUs = Brand<number, 'GuardIntervalUs'>;
|
||||||
|
|
||||||
|
export const WIFI_GENERATIONS = {
|
||||||
|
UNKNOWN: 0,
|
||||||
|
WIFI_4: 4,
|
||||||
|
WIFI_5: 5,
|
||||||
|
WIFI_6: 6,
|
||||||
|
WIFI_7: 7,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type WifiGeneration = (typeof WIFI_GENERATIONS)[keyof typeof WIFI_GENERATIONS];
|
||||||
|
|
||||||
|
export function isKnownGeneration(gen: WifiGeneration): gen is 4 | 5 | 6 | 7 {
|
||||||
|
return gen >= WIFI_GENERATIONS.WIFI_4 && gen <= WIFI_GENERATIONS.WIFI_7;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IEEE_STANDARDS = {
|
||||||
|
[WIFI_GENERATIONS.WIFI_4]: '802.11n',
|
||||||
|
[WIFI_GENERATIONS.WIFI_5]: '802.11ac',
|
||||||
|
[WIFI_GENERATIONS.WIFI_6]: '802.11ax',
|
||||||
|
[WIFI_GENERATIONS.WIFI_7]: '802.11be',
|
||||||
|
[WIFI_GENERATIONS.UNKNOWN]: 'Unknown',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type IeeeStandard = (typeof IEEE_STANDARDS)[WifiGeneration];
|
||||||
|
|
||||||
|
export const FREQUENCY_BANDS = ['2.4 GHz', '5 GHz', '6 GHz', 'Unknown'] as const;
|
||||||
|
export type FrequencyBand = (typeof FREQUENCY_BANDS)[number];
|
||||||
|
|
||||||
|
export const SIGNAL_QUALITIES = ['Excellent', 'Good', 'Fair', 'Weak', 'Poor', 'Unknown'] as const;
|
||||||
|
export type SignalQuality = (typeof SIGNAL_QUALITIES)[number];
|
||||||
|
|
||||||
|
export const SIGNAL_THRESHOLDS = {
|
||||||
|
Excellent: -50,
|
||||||
|
Good: -60,
|
||||||
|
Fair: -70,
|
||||||
|
Weak: -80,
|
||||||
|
} as const satisfies Record<Exclude<SignalQuality, 'Poor' | 'Unknown'>, number>;
|
||||||
|
|
||||||
|
export const SECURITY_PROTOCOLS = [
|
||||||
|
'WPA3',
|
||||||
|
'WPA2-Enterprise',
|
||||||
|
'WPA2',
|
||||||
|
'WPA-Enterprise',
|
||||||
|
'WPA',
|
||||||
|
'Open',
|
||||||
|
'Unknown',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SecurityProtocol = (typeof SECURITY_PROTOCOLS)[number];
|
||||||
|
|
||||||
|
export type GenerationCssClass = `wifi-gen-${4 | 5 | 6 | 7}` | 'wifi-disconnected';
|
||||||
|
export type SignalCssClass = `wifi-signal-${Lowercase<Exclude<SignalQuality, 'Unknown'>>}` | '';
|
||||||
|
|
||||||
|
export const GENERATION_CSS_CLASSES = {
|
||||||
|
[WIFI_GENERATIONS.WIFI_4]: 'wifi-gen-4',
|
||||||
|
[WIFI_GENERATIONS.WIFI_5]: 'wifi-gen-5',
|
||||||
|
[WIFI_GENERATIONS.WIFI_6]: 'wifi-gen-6',
|
||||||
|
[WIFI_GENERATIONS.WIFI_7]: 'wifi-gen-7',
|
||||||
|
[WIFI_GENERATIONS.UNKNOWN]: 'wifi-disconnected',
|
||||||
|
} as const satisfies Record<WifiGeneration, GenerationCssClass>;
|
||||||
|
|
||||||
|
export const GUARD_INTERVALS = {
|
||||||
|
SHORT: 0.4 as GuardIntervalUs,
|
||||||
|
NORMAL: 0.8 as GuardIntervalUs,
|
||||||
|
LONG_1: 1.6 as GuardIntervalUs,
|
||||||
|
LONG_2: 3.2 as GuardIntervalUs,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const HE_GI_INDEX_MAP = {
|
||||||
|
0: GUARD_INTERVALS.NORMAL,
|
||||||
|
1: GUARD_INTERVALS.LONG_1,
|
||||||
|
2: GUARD_INTERVALS.LONG_2,
|
||||||
|
} as const satisfies Record<0 | 1 | 2, GuardIntervalUs>;
|
||||||
|
|
||||||
|
export interface IwLinkInfo {
|
||||||
|
readonly generation: WifiGeneration;
|
||||||
|
readonly standard: IeeeStandard | null;
|
||||||
|
readonly mcs: McsIndex | null;
|
||||||
|
readonly nss: SpatialStreams | null;
|
||||||
|
readonly guardInterval: GuardIntervalUs | null;
|
||||||
|
readonly channelWidth: ChannelWidthMHz | null;
|
||||||
|
readonly txBitrate: BitrateMbps | null;
|
||||||
|
readonly rxBitrate: BitrateMbps | null;
|
||||||
|
readonly signal: SignalDbm | null;
|
||||||
|
readonly frequency: FrequencyMHz | null;
|
||||||
|
readonly ssid: string | null;
|
||||||
|
readonly bssid: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseConnectionInfo {
|
||||||
|
readonly interfaceName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisconnectedInfo extends BaseConnectionInfo {
|
||||||
|
readonly connected: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectedInfo extends BaseConnectionInfo {
|
||||||
|
readonly connected: true;
|
||||||
|
readonly ssid: string;
|
||||||
|
readonly bssid: string;
|
||||||
|
readonly frequency: FrequencyMHz;
|
||||||
|
readonly channel: ChannelNumber;
|
||||||
|
readonly band: FrequencyBand;
|
||||||
|
readonly signalStrength: SignalDbm;
|
||||||
|
readonly signalPercent: SignalPercent;
|
||||||
|
readonly bitrate: BitrateMbps;
|
||||||
|
readonly security: SecurityProtocol;
|
||||||
|
readonly generation: WifiGeneration;
|
||||||
|
readonly standard: IeeeStandard | null;
|
||||||
|
readonly mcs: McsIndex | null;
|
||||||
|
readonly nss: SpatialStreams | null;
|
||||||
|
readonly guardInterval: GuardIntervalUs | null;
|
||||||
|
readonly channelWidth: ChannelWidthMHz | null;
|
||||||
|
readonly txBitrate: BitrateMbps | null;
|
||||||
|
readonly rxBitrate: BitrateMbps | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WifiConnectionInfo = DisconnectedInfo | ConnectedInfo;
|
||||||
|
|
||||||
|
export function isConnected(info: WifiConnectionInfo): info is ConnectedInfo {
|
||||||
|
return info.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const asFrequencyMHz = (value: number): FrequencyMHz => value as FrequencyMHz;
|
||||||
|
export const asSignalDbm = (value: number): SignalDbm => value as SignalDbm;
|
||||||
|
export const asSignalPercent = (value: number): SignalPercent => value as SignalPercent;
|
||||||
|
export const asBitrateMbps = (value: number): BitrateMbps => value as BitrateMbps;
|
||||||
|
export const asChannelWidthMHz = (value: number): ChannelWidthMHz => value as ChannelWidthMHz;
|
||||||
|
export const asChannelNumber = (value: number): ChannelNumber => value as ChannelNumber;
|
||||||
|
export const asMcsIndex = (value: number): McsIndex => value as McsIndex;
|
||||||
|
export const asSpatialStreams = (value: number): SpatialStreams => value as SpatialStreams;
|
||||||
|
export const asGuardIntervalUs = (value: number): GuardIntervalUs => value as GuardIntervalUs;
|
||||||
|
|
||||||
|
export function createEmptyIwLinkInfo(): IwLinkInfo {
|
||||||
|
return Object.freeze({
|
||||||
|
generation: WIFI_GENERATIONS.UNKNOWN,
|
||||||
|
standard: null,
|
||||||
|
mcs: null,
|
||||||
|
nss: null,
|
||||||
|
guardInterval: null,
|
||||||
|
channelWidth: null,
|
||||||
|
txBitrate: null,
|
||||||
|
rxBitrate: null,
|
||||||
|
signal: null,
|
||||||
|
frequency: null,
|
||||||
|
ssid: null,
|
||||||
|
bssid: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDisconnectedInfo(interfaceName: string | null = null): DisconnectedInfo {
|
||||||
|
return Object.freeze({
|
||||||
|
connected: false as const,
|
||||||
|
interfaceName,
|
||||||
|
});
|
||||||
|
}
|
||||||
283
src/wifiGeneration.test.ts
Normal file
283
src/wifiGeneration.test.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
parseIwLinkOutput,
|
||||||
|
createEmptyIwLinkInfo,
|
||||||
|
WIFI_GENERATIONS,
|
||||||
|
IEEE_STANDARDS,
|
||||||
|
GENERATION_CSS_CLASSES,
|
||||||
|
getGenerationLabel,
|
||||||
|
getGenerationDescription,
|
||||||
|
isKnownGeneration,
|
||||||
|
} from './wifiGeneration';
|
||||||
|
import { GUARD_INTERVALS } from './types';
|
||||||
|
|
||||||
|
describe('createEmptyIwLinkInfo', () => {
|
||||||
|
it('should create an object with all null values and UNKNOWN generation', () => {
|
||||||
|
const info = createEmptyIwLinkInfo();
|
||||||
|
|
||||||
|
expect(info.generation).toBe(WIFI_GENERATIONS.UNKNOWN);
|
||||||
|
expect(info.standard).toBeNull();
|
||||||
|
expect(info.mcs).toBeNull();
|
||||||
|
expect(info.nss).toBeNull();
|
||||||
|
expect(info.guardInterval).toBeNull();
|
||||||
|
expect(info.channelWidth).toBeNull();
|
||||||
|
expect(info.txBitrate).toBeNull();
|
||||||
|
expect(info.rxBitrate).toBeNull();
|
||||||
|
expect(info.signal).toBeNull();
|
||||||
|
expect(info.frequency).toBeNull();
|
||||||
|
expect(info.ssid).toBeNull();
|
||||||
|
expect(info.bssid).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a frozen object', () => {
|
||||||
|
const info = createEmptyIwLinkInfo();
|
||||||
|
expect(Object.isFrozen(info)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isKnownGeneration', () => {
|
||||||
|
it('should return true for known generations', () => {
|
||||||
|
expect(isKnownGeneration(WIFI_GENERATIONS.WIFI_4)).toBe(true);
|
||||||
|
expect(isKnownGeneration(WIFI_GENERATIONS.WIFI_5)).toBe(true);
|
||||||
|
expect(isKnownGeneration(WIFI_GENERATIONS.WIFI_6)).toBe(true);
|
||||||
|
expect(isKnownGeneration(WIFI_GENERATIONS.WIFI_7)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for UNKNOWN', () => {
|
||||||
|
expect(isKnownGeneration(WIFI_GENERATIONS.UNKNOWN)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseIwLinkOutput', () => {
|
||||||
|
it('should return empty info for empty input', () => {
|
||||||
|
const result = parseIwLinkOutput('');
|
||||||
|
|
||||||
|
expect(result.generation).toBe(WIFI_GENERATIONS.UNKNOWN);
|
||||||
|
expect(result.ssid).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty info for "Not connected"', () => {
|
||||||
|
const result = parseIwLinkOutput('Not connected.');
|
||||||
|
|
||||||
|
expect(result.generation).toBe(WIFI_GENERATIONS.UNKNOWN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a frozen result', () => {
|
||||||
|
const result = parseIwLinkOutput('Connected to 00:00:00:00:00:00 (on wlan0)');
|
||||||
|
expect(Object.isFrozen(result)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WiFi 6 (HE) detection', () => {
|
||||||
|
it('should detect WiFi 6 from real iw output', () => {
|
||||||
|
const iwOutput = `Connected to ae:8b:a9:51:30:23 (on wlp192s0)
|
||||||
|
SSID: LaccordeonCoworking
|
||||||
|
freq: 5220.0
|
||||||
|
RX: 1533905496 bytes (1321800 packets)
|
||||||
|
TX: 220138288 bytes (525917 packets)
|
||||||
|
signal: -39 dBm
|
||||||
|
rx bitrate: 573.5 MBit/s 40MHz HE-MCS 11 HE-NSS 2 HE-GI 0 HE-DCM 0
|
||||||
|
tx bitrate: 573.5 MBit/s 40MHz HE-MCS 11 HE-NSS 2 HE-GI 0 HE-DCM 0
|
||||||
|
bss flags: short-slot-time
|
||||||
|
dtim period: 3
|
||||||
|
beacon int: 100`;
|
||||||
|
|
||||||
|
const result = parseIwLinkOutput(iwOutput);
|
||||||
|
|
||||||
|
expect(result.generation).toBe(WIFI_GENERATIONS.WIFI_6);
|
||||||
|
expect(result.standard).toBe('802.11ax');
|
||||||
|
expect(result.ssid).toBe('LaccordeonCoworking');
|
||||||
|
expect(result.bssid).toBe('ae:8b:a9:51:30:23');
|
||||||
|
expect(result.frequency).toBe(5220.0);
|
||||||
|
expect(result.signal).toBe(-39);
|
||||||
|
expect(result.mcs).toBe(11);
|
||||||
|
expect(result.nss).toBe(2);
|
||||||
|
expect(result.guardInterval).toBe(GUARD_INTERVALS.NORMAL);
|
||||||
|
expect(result.channelWidth).toBe(40);
|
||||||
|
expect(result.txBitrate).toBe(573.5);
|
||||||
|
expect(result.rxBitrate).toBe(573.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse all HE guard interval values correctly', () => {
|
||||||
|
const testCases = [
|
||||||
|
{ gi: '0', expected: GUARD_INTERVALS.NORMAL },
|
||||||
|
{ gi: '1', expected: GUARD_INTERVALS.LONG_1 },
|
||||||
|
{ gi: '2', expected: GUARD_INTERVALS.LONG_2 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const { gi, expected } of testCases) {
|
||||||
|
const iwOutput = `Connected to 00:00:00:00:00:00 (on wlan0)
|
||||||
|
tx bitrate: 100 MBit/s HE-MCS 5 HE-NSS 1 HE-GI ${gi}`;
|
||||||
|
|
||||||
|
const result = parseIwLinkOutput(iwOutput);
|
||||||
|
|
||||||
|
expect(result.guardInterval).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WiFi 5 (VHT) detection', () => {
|
||||||
|
it('should detect WiFi 5 connection', () => {
|
||||||
|
const iwOutput = `Connected to 00:11:22:33:44:55 (on wlan0)
|
||||||
|
SSID: MyNetwork
|
||||||
|
freq: 5180
|
||||||
|
signal: -55 dBm
|
||||||
|
tx bitrate: 866.7 MBit/s VHT-MCS 9 80MHz VHT-NSS 2
|
||||||
|
rx bitrate: 650.0 MBit/s VHT-MCS 7 80MHz short GI VHT-NSS 2`;
|
||||||
|
|
||||||
|
const result = parseIwLinkOutput(iwOutput);
|
||||||
|
|
||||||
|
expect(result.generation).toBe(WIFI_GENERATIONS.WIFI_5);
|
||||||
|
expect(result.standard).toBe('802.11ac');
|
||||||
|
expect(result.mcs).toBe(9);
|
||||||
|
expect(result.nss).toBe(2);
|
||||||
|
expect(result.channelWidth).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect short GI for VHT', () => {
|
||||||
|
const iwOutput = `Connected to 00:00:00:00:00:00 (on wlan0)
|
||||||
|
tx bitrate: 100 MBit/s VHT-MCS 5 20MHz short GI VHT-NSS 1`;
|
||||||
|
|
||||||
|
const result = parseIwLinkOutput(iwOutput);
|
||||||
|
|
||||||
|
expect(result.guardInterval).toBe(GUARD_INTERVALS.SHORT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use normal GI when short GI not present', () => {
|
||||||
|
const iwOutput = `Connected to 00:00:00:00:00:00 (on wlan0)
|
||||||
|
tx bitrate: 100 MBit/s VHT-MCS 5 20MHz VHT-NSS 1`;
|
||||||
|
|
||||||
|
const result = parseIwLinkOutput(iwOutput);
|
||||||
|
|
||||||
|
expect(result.guardInterval).toBe(GUARD_INTERVALS.NORMAL);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WiFi 4 (HT) detection', () => {
|
||||||
|
it('should detect WiFi 4 connection', () => {
|
||||||
|
const iwOutput = `Connected to aa:bb:cc:dd:ee:ff (on wlan0)
|
||||||
|
SSID: OldRouter
|
||||||
|
freq: 2437
|
||||||
|
signal: -65 dBm
|
||||||
|
tx bitrate: 72.2 MBit/s MCS 7 20MHz short GI
|
||||||
|
rx bitrate: 65.0 MBit/s MCS 6 20MHz`;
|
||||||
|
|
||||||
|
const result = parseIwLinkOutput(iwOutput);
|
||||||
|
|
||||||
|
expect(result.generation).toBe(WIFI_GENERATIONS.WIFI_4);
|
||||||
|
expect(result.standard).toBe('802.11n');
|
||||||
|
expect(result.mcs).toBe(7);
|
||||||
|
expect(result.nss).toBe(1);
|
||||||
|
expect(result.channelWidth).toBe(20);
|
||||||
|
expect(result.guardInterval).toBe(GUARD_INTERVALS.SHORT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should derive NSS from MCS index', () => {
|
||||||
|
const testCases = [
|
||||||
|
{ mcs: 0, expectedNss: 1 },
|
||||||
|
{ mcs: 7, expectedNss: 1 },
|
||||||
|
{ mcs: 8, expectedNss: 2 },
|
||||||
|
{ mcs: 15, expectedNss: 2 },
|
||||||
|
{ mcs: 16, expectedNss: 3 },
|
||||||
|
{ mcs: 23, expectedNss: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { mcs, expectedNss } of testCases) {
|
||||||
|
const iwOutput = `Connected to 00:00:00:00:00:00 (on wlan0)
|
||||||
|
tx bitrate: 100 MBit/s MCS ${mcs} 20MHz`;
|
||||||
|
|
||||||
|
const result = parseIwLinkOutput(iwOutput);
|
||||||
|
|
||||||
|
expect(result.nss).toBe(expectedNss);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WiFi 7 (EHT) detection', () => {
|
||||||
|
it('should detect WiFi 7 connection', () => {
|
||||||
|
const iwOutput = `Connected to 11:22:33:44:55:66 (on wlan0)
|
||||||
|
SSID: WiFi7Network
|
||||||
|
freq: 6115
|
||||||
|
signal: -45 dBm
|
||||||
|
tx bitrate: 2882.4 MBit/s 160MHz EHT-MCS 13 EHT-NSS 2 EHT-GI 0
|
||||||
|
rx bitrate: 2882.4 MBit/s 160MHz EHT-MCS 13 EHT-NSS 2 EHT-GI 0`;
|
||||||
|
|
||||||
|
const result = parseIwLinkOutput(iwOutput);
|
||||||
|
|
||||||
|
expect(result.generation).toBe(WIFI_GENERATIONS.WIFI_7);
|
||||||
|
expect(result.standard).toBe('802.11be');
|
||||||
|
expect(result.mcs).toBe(13);
|
||||||
|
expect(result.nss).toBe(2);
|
||||||
|
expect(result.channelWidth).toBe(160);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fallback to RX bitrate', () => {
|
||||||
|
it('should use RX bitrate when TX has no generation info', () => {
|
||||||
|
const iwOutput = `Connected to 00:00:00:00:00:00 (on wlan0)
|
||||||
|
tx bitrate: 100 MBit/s
|
||||||
|
rx bitrate: 200 MBit/s HE-MCS 9 HE-NSS 2 HE-GI 1 40MHz`;
|
||||||
|
|
||||||
|
const result = parseIwLinkOutput(iwOutput);
|
||||||
|
|
||||||
|
expect(result.generation).toBe(WIFI_GENERATIONS.WIFI_6);
|
||||||
|
expect(result.txBitrate).toBe(100);
|
||||||
|
expect(result.rxBitrate).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGenerationLabel', () => {
|
||||||
|
it('should return "WiFi X" for known generations', () => {
|
||||||
|
expect(getGenerationLabel(WIFI_GENERATIONS.WIFI_4)).toBe('WiFi 4');
|
||||||
|
expect(getGenerationLabel(WIFI_GENERATIONS.WIFI_5)).toBe('WiFi 5');
|
||||||
|
expect(getGenerationLabel(WIFI_GENERATIONS.WIFI_6)).toBe('WiFi 6');
|
||||||
|
expect(getGenerationLabel(WIFI_GENERATIONS.WIFI_7)).toBe('WiFi 7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "WiFi" for UNKNOWN', () => {
|
||||||
|
expect(getGenerationLabel(WIFI_GENERATIONS.UNKNOWN)).toBe('WiFi');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGenerationDescription', () => {
|
||||||
|
it('should return full description with IEEE standard', () => {
|
||||||
|
expect(getGenerationDescription(WIFI_GENERATIONS.WIFI_4)).toBe('WiFi 4 (802.11n)');
|
||||||
|
expect(getGenerationDescription(WIFI_GENERATIONS.WIFI_5)).toBe('WiFi 5 (802.11ac)');
|
||||||
|
expect(getGenerationDescription(WIFI_GENERATIONS.WIFI_6)).toBe('WiFi 6 (802.11ax)');
|
||||||
|
expect(getGenerationDescription(WIFI_GENERATIONS.WIFI_7)).toBe('WiFi 7 (802.11be)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "WiFi" for UNKNOWN', () => {
|
||||||
|
expect(getGenerationDescription(WIFI_GENERATIONS.UNKNOWN)).toBe('WiFi');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IEEE_STANDARDS', () => {
|
||||||
|
it('should map all generations to their IEEE standards', () => {
|
||||||
|
expect(IEEE_STANDARDS[WIFI_GENERATIONS.WIFI_4]).toBe('802.11n');
|
||||||
|
expect(IEEE_STANDARDS[WIFI_GENERATIONS.WIFI_5]).toBe('802.11ac');
|
||||||
|
expect(IEEE_STANDARDS[WIFI_GENERATIONS.WIFI_6]).toBe('802.11ax');
|
||||||
|
expect(IEEE_STANDARDS[WIFI_GENERATIONS.WIFI_7]).toBe('802.11be');
|
||||||
|
expect(IEEE_STANDARDS[WIFI_GENERATIONS.UNKNOWN]).toBe('Unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GENERATION_CSS_CLASSES', () => {
|
||||||
|
it('should map all generations to CSS classes', () => {
|
||||||
|
expect(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.WIFI_4]).toBe('wifi-gen-4');
|
||||||
|
expect(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.WIFI_5]).toBe('wifi-gen-5');
|
||||||
|
expect(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.WIFI_6]).toBe('wifi-gen-6');
|
||||||
|
expect(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.WIFI_7]).toBe('wifi-gen-7');
|
||||||
|
expect(GENERATION_CSS_CLASSES[WIFI_GENERATIONS.UNKNOWN]).toBe('wifi-disconnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use template literal type pattern', () => {
|
||||||
|
// Type check: all values should match the GenerationCssClass type
|
||||||
|
const classes = Object.values(GENERATION_CSS_CLASSES);
|
||||||
|
expect(classes).toContain('wifi-gen-4');
|
||||||
|
expect(classes).toContain('wifi-gen-5');
|
||||||
|
expect(classes).toContain('wifi-gen-6');
|
||||||
|
expect(classes).toContain('wifi-gen-7');
|
||||||
|
expect(classes).toContain('wifi-disconnected');
|
||||||
|
});
|
||||||
|
});
|
||||||
287
src/wifiGeneration.ts
Normal file
287
src/wifiGeneration.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
/**
|
||||||
|
* WiFi Generation Detection
|
||||||
|
*
|
||||||
|
* Parses `iw dev <interface> link` output to detect WiFi 4/5/6/7.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type IwLinkInfo,
|
||||||
|
type WifiGeneration,
|
||||||
|
type McsIndex,
|
||||||
|
type SpatialStreams,
|
||||||
|
type GuardIntervalUs,
|
||||||
|
type ChannelWidthMHz,
|
||||||
|
type BitrateMbps,
|
||||||
|
type SignalDbm,
|
||||||
|
type FrequencyMHz,
|
||||||
|
WIFI_GENERATIONS,
|
||||||
|
IEEE_STANDARDS,
|
||||||
|
GENERATION_CSS_CLASSES,
|
||||||
|
GUARD_INTERVALS,
|
||||||
|
HE_GI_INDEX_MAP,
|
||||||
|
createEmptyIwLinkInfo,
|
||||||
|
isKnownGeneration,
|
||||||
|
asMcsIndex,
|
||||||
|
asSpatialStreams,
|
||||||
|
asChannelWidthMHz,
|
||||||
|
asBitrateMbps,
|
||||||
|
asSignalDbm,
|
||||||
|
asFrequencyMHz,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
type IwLinkInfo,
|
||||||
|
type WifiGeneration,
|
||||||
|
WIFI_GENERATIONS,
|
||||||
|
IEEE_STANDARDS,
|
||||||
|
GENERATION_CSS_CLASSES,
|
||||||
|
createEmptyIwLinkInfo,
|
||||||
|
isKnownGeneration,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BitrateParseResult {
|
||||||
|
readonly bitrate: BitrateMbps | null;
|
||||||
|
readonly generation: WifiGeneration;
|
||||||
|
readonly mcs: McsIndex | null;
|
||||||
|
readonly nss: SpatialStreams | null;
|
||||||
|
readonly guardInterval: GuardIntervalUs | null;
|
||||||
|
readonly channelWidth: ChannelWidthMHz | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MutableParseResult {
|
||||||
|
generation: WifiGeneration;
|
||||||
|
standard: (typeof IEEE_STANDARDS)[WifiGeneration] | null;
|
||||||
|
mcs: McsIndex | null;
|
||||||
|
nss: SpatialStreams | null;
|
||||||
|
guardInterval: GuardIntervalUs | null;
|
||||||
|
channelWidth: ChannelWidthMHz | null;
|
||||||
|
txBitrate: BitrateMbps | null;
|
||||||
|
rxBitrate: BitrateMbps | null;
|
||||||
|
signal: SignalDbm | null;
|
||||||
|
frequency: FrequencyMHz | null;
|
||||||
|
ssid: string | null;
|
||||||
|
bssid: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerationDetectionResult {
|
||||||
|
readonly generation: WifiGeneration;
|
||||||
|
readonly mcs: McsIndex | null;
|
||||||
|
readonly nss: SpatialStreams | null;
|
||||||
|
readonly guardInterval: GuardIntervalUs | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseIwLinkOutput(iwOutput: string): IwLinkInfo {
|
||||||
|
if (!iwOutput || iwOutput.includes('Not connected')) {
|
||||||
|
return createEmptyIwLinkInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = createMutableResult();
|
||||||
|
|
||||||
|
for (const line of iwOutput.split('\n')) {
|
||||||
|
parseLine(line.trim(), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return freezeResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMutableResult(): MutableParseResult {
|
||||||
|
return {
|
||||||
|
generation: WIFI_GENERATIONS.UNKNOWN,
|
||||||
|
standard: null,
|
||||||
|
mcs: null,
|
||||||
|
nss: null,
|
||||||
|
guardInterval: null,
|
||||||
|
channelWidth: null,
|
||||||
|
txBitrate: null,
|
||||||
|
rxBitrate: null,
|
||||||
|
signal: null,
|
||||||
|
frequency: null,
|
||||||
|
ssid: null,
|
||||||
|
bssid: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeResult(result: MutableParseResult): IwLinkInfo {
|
||||||
|
if (isKnownGeneration(result.generation)) {
|
||||||
|
result.standard = IEEE_STANDARDS[result.generation];
|
||||||
|
}
|
||||||
|
return Object.freeze(result) as IwLinkInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLine(line: string, result: MutableParseResult): void {
|
||||||
|
parseConnectionInfo(line, result);
|
||||||
|
parseBitrateLines(line, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConnectionInfo(line: string, result: MutableParseResult): void {
|
||||||
|
if (line.startsWith('SSID:')) {
|
||||||
|
result.ssid = line.substring(5).trim();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('Connected to')) {
|
||||||
|
const match = line.match(/Connected to ([0-9a-f:]+)/i);
|
||||||
|
if (match) {
|
||||||
|
result.bssid = match[1];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('freq:')) {
|
||||||
|
const value = parseFloat(line.substring(5).trim());
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
result.frequency = asFrequencyMHz(value);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('signal:')) {
|
||||||
|
const match = line.match(/signal:\s*(-?\d+)/);
|
||||||
|
if (match) {
|
||||||
|
result.signal = asSignalDbm(parseInt(match[1], 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBitrateLines(line: string, result: MutableParseResult): void {
|
||||||
|
if (line.startsWith('tx bitrate:')) {
|
||||||
|
const bitrateInfo = parseBitrateLine(line);
|
||||||
|
result.txBitrate = bitrateInfo.bitrate;
|
||||||
|
applyBitrateInfoIfDetected(bitrateInfo, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('rx bitrate:')) {
|
||||||
|
const bitrateInfo = parseBitrateLine(line);
|
||||||
|
result.rxBitrate = bitrateInfo.bitrate;
|
||||||
|
if (result.generation === WIFI_GENERATIONS.UNKNOWN) {
|
||||||
|
applyBitrateInfoIfDetected(bitrateInfo, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBitrateInfoIfDetected(
|
||||||
|
bitrateInfo: BitrateParseResult,
|
||||||
|
result: MutableParseResult
|
||||||
|
): void {
|
||||||
|
if (bitrateInfo.generation === WIFI_GENERATIONS.UNKNOWN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result.generation = bitrateInfo.generation;
|
||||||
|
result.mcs = bitrateInfo.mcs;
|
||||||
|
result.nss = bitrateInfo.nss;
|
||||||
|
result.guardInterval = bitrateInfo.guardInterval;
|
||||||
|
result.channelWidth = bitrateInfo.channelWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBitrateLine(line: string): BitrateParseResult {
|
||||||
|
const bitrate = parseNumericValue(line, /(\d+\.?\d*)\s*MBit\/s/);
|
||||||
|
const channelWidth = parseNumericValue(line, /(\d+)MHz/);
|
||||||
|
const generationInfo = detectWifiGeneration(line);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bitrate: bitrate !== null ? asBitrateMbps(bitrate) : null,
|
||||||
|
generation: generationInfo.generation,
|
||||||
|
mcs: generationInfo.mcs,
|
||||||
|
nss: generationInfo.nss,
|
||||||
|
guardInterval: generationInfo.guardInterval,
|
||||||
|
channelWidth: channelWidth !== null ? asChannelWidthMHz(channelWidth) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumericValue(line: string, pattern: RegExp): number | null {
|
||||||
|
const match = line.match(pattern);
|
||||||
|
if (!match) return null;
|
||||||
|
const value = parseFloat(match[1]);
|
||||||
|
return Number.isNaN(value) ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectWifiGeneration(line: string): GenerationDetectionResult {
|
||||||
|
return (
|
||||||
|
tryParseEHT(line) ??
|
||||||
|
tryParseHE(line) ??
|
||||||
|
tryParseVHT(line) ??
|
||||||
|
tryParseHT(line) ?? {
|
||||||
|
generation: WIFI_GENERATIONS.UNKNOWN,
|
||||||
|
mcs: null,
|
||||||
|
nss: null,
|
||||||
|
guardInterval: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseEHT(line: string): GenerationDetectionResult | null {
|
||||||
|
if (!line.includes('EHT-MCS')) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
generation: WIFI_GENERATIONS.WIFI_7,
|
||||||
|
mcs: parseMcs(line, /EHT-MCS\s+(\d+)/),
|
||||||
|
nss: parseNss(line, /EHT-NSS\s+(\d+)/),
|
||||||
|
guardInterval: parseHeGuardInterval(line, 'EHT-GI'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseHE(line: string): GenerationDetectionResult | null {
|
||||||
|
if (!line.includes('HE-MCS')) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
generation: WIFI_GENERATIONS.WIFI_6,
|
||||||
|
mcs: parseMcs(line, /HE-MCS\s+(\d+)/),
|
||||||
|
nss: parseNss(line, /HE-NSS\s+(\d+)/),
|
||||||
|
guardInterval: parseHeGuardInterval(line, 'HE-GI'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseVHT(line: string): GenerationDetectionResult | null {
|
||||||
|
if (!line.includes('VHT-MCS')) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
generation: WIFI_GENERATIONS.WIFI_5,
|
||||||
|
mcs: parseMcs(line, /VHT-MCS\s+(\d+)/),
|
||||||
|
nss: parseNss(line, /VHT-NSS\s+(\d+)/),
|
||||||
|
guardInterval: line.includes('short GI') ? GUARD_INTERVALS.SHORT : GUARD_INTERVALS.NORMAL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseHT(line: string): GenerationDetectionResult | null {
|
||||||
|
if (!line.match(/\bMCS\s+\d+/) || line.includes('-MCS')) return null;
|
||||||
|
|
||||||
|
const mcs = parseMcs(line, /\bMCS\s+(\d+)/);
|
||||||
|
|
||||||
|
return {
|
||||||
|
generation: WIFI_GENERATIONS.WIFI_4,
|
||||||
|
mcs,
|
||||||
|
nss: mcs !== null ? asSpatialStreams(Math.floor(mcs / 8) + 1) : null,
|
||||||
|
guardInterval: line.includes('short GI') ? GUARD_INTERVALS.SHORT : GUARD_INTERVALS.NORMAL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMcs(line: string, pattern: RegExp): McsIndex | null {
|
||||||
|
const value = parseNumericValue(line, pattern);
|
||||||
|
return value !== null ? asMcsIndex(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNss(line: string, pattern: RegExp): SpatialStreams | null {
|
||||||
|
const value = parseNumericValue(line, pattern);
|
||||||
|
return value !== null ? asSpatialStreams(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeGuardInterval(line: string, prefix: string): GuardIntervalUs {
|
||||||
|
const pattern = new RegExp(`${prefix}\\s+(\\d+)`);
|
||||||
|
const match = line.match(pattern);
|
||||||
|
|
||||||
|
if (!match) return GUARD_INTERVALS.NORMAL;
|
||||||
|
|
||||||
|
const giIndex = parseInt(match[1], 10) as 0 | 1 | 2;
|
||||||
|
return HE_GI_INDEX_MAP[giIndex] ?? GUARD_INTERVALS.NORMAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGenerationLabel(generation: WifiGeneration): string {
|
||||||
|
return isKnownGeneration(generation) ? `WiFi ${generation}` : 'WiFi';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGenerationDescription(generation: WifiGeneration): string {
|
||||||
|
return isKnownGeneration(generation)
|
||||||
|
? `WiFi ${generation} (${IEEE_STANDARDS[generation]})`
|
||||||
|
: 'WiFi';
|
||||||
|
}
|
||||||
271
src/wifiInfo.ts
Normal file
271
src/wifiInfo.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
/**
|
||||||
|
* WiFi Information Service
|
||||||
|
*
|
||||||
|
* Retrieves connection details from NetworkManager and `iw` command.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import GLib from 'gi://GLib';
|
||||||
|
import NM from 'gi://NM';
|
||||||
|
|
||||||
|
import { parseIwLinkOutput, createEmptyIwLinkInfo } from './wifiGeneration.js';
|
||||||
|
import {
|
||||||
|
type WifiConnectionInfo,
|
||||||
|
type ConnectedInfo,
|
||||||
|
type DisconnectedInfo,
|
||||||
|
type FrequencyMHz,
|
||||||
|
type FrequencyBand,
|
||||||
|
type ChannelNumber,
|
||||||
|
type SignalDbm,
|
||||||
|
type SignalQuality,
|
||||||
|
type SecurityProtocol,
|
||||||
|
type SignalCssClass,
|
||||||
|
SIGNAL_THRESHOLDS,
|
||||||
|
createDisconnectedInfo,
|
||||||
|
isConnected,
|
||||||
|
asFrequencyMHz,
|
||||||
|
asSignalDbm,
|
||||||
|
asSignalPercent,
|
||||||
|
asBitrateMbps,
|
||||||
|
asChannelNumber,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
type WifiConnectionInfo,
|
||||||
|
type ConnectedInfo,
|
||||||
|
type DisconnectedInfo,
|
||||||
|
type SignalQuality,
|
||||||
|
isConnected,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLACEHOLDER = '--' as const;
|
||||||
|
|
||||||
|
export class WifiInfoService {
|
||||||
|
private client: NM.Client | null = null;
|
||||||
|
private initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (this.client) return;
|
||||||
|
if (this.initPromise) return this.initPromise;
|
||||||
|
|
||||||
|
this.initPromise = new Promise((resolve, reject) => {
|
||||||
|
NM.Client.new_async(null, (_obj, result) => {
|
||||||
|
try {
|
||||||
|
this.client = NM.Client.new_finish(result);
|
||||||
|
resolve();
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.client = null;
|
||||||
|
this.initPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnectionInfo(): WifiConnectionInfo {
|
||||||
|
if (!this.client) {
|
||||||
|
return createDisconnectedInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
const wifiDevice = this.findActiveWifiDevice();
|
||||||
|
if (!wifiDevice) {
|
||||||
|
return createDisconnectedInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
const interfaceName = wifiDevice.get_iface();
|
||||||
|
const activeAp = wifiDevice.get_active_access_point();
|
||||||
|
|
||||||
|
if (!activeAp) {
|
||||||
|
return createDisconnectedInfo(interfaceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.buildConnectedInfo(wifiDevice, activeAp, interfaceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildConnectedInfo(
|
||||||
|
device: NM.DeviceWifi,
|
||||||
|
ap: NM.AccessPoint,
|
||||||
|
interfaceName: string | null
|
||||||
|
): ConnectedInfo {
|
||||||
|
const iwInfo = this.executeIwLink(interfaceName);
|
||||||
|
const frequency = asFrequencyMHz(ap.get_frequency());
|
||||||
|
const strengthPercent = ap.get_strength();
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
connected: true as const,
|
||||||
|
interfaceName,
|
||||||
|
ssid: this.decodeSsid(ap.get_ssid()) ?? 'Unknown',
|
||||||
|
bssid: ap.get_bssid() ?? 'Unknown',
|
||||||
|
frequency,
|
||||||
|
channel: frequencyToChannel(frequency),
|
||||||
|
band: frequencyToBand(frequency),
|
||||||
|
signalStrength: iwInfo.signal ?? estimateSignalDbm(strengthPercent),
|
||||||
|
signalPercent: asSignalPercent(strengthPercent),
|
||||||
|
bitrate: asBitrateMbps(device.get_bitrate() / 1000),
|
||||||
|
security: getSecurityProtocol(ap),
|
||||||
|
generation: iwInfo.generation,
|
||||||
|
standard: iwInfo.standard,
|
||||||
|
mcs: iwInfo.mcs,
|
||||||
|
nss: iwInfo.nss,
|
||||||
|
guardInterval: iwInfo.guardInterval,
|
||||||
|
channelWidth: iwInfo.channelWidth,
|
||||||
|
txBitrate: iwInfo.txBitrate,
|
||||||
|
rxBitrate: iwInfo.rxBitrate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private findActiveWifiDevice(): NM.DeviceWifi | null {
|
||||||
|
if (!this.client) return null;
|
||||||
|
|
||||||
|
const devices = this.client.get_devices();
|
||||||
|
|
||||||
|
for (const device of devices) {
|
||||||
|
if (device instanceof NM.DeviceWifi && device.get_state() === NM.DeviceState.ACTIVATED) {
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const device of devices) {
|
||||||
|
if (device instanceof NM.DeviceWifi) {
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeIwLink(interfaceName: string | null) {
|
||||||
|
if (!interfaceName) {
|
||||||
|
return createEmptyIwLinkInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [success, stdout] = GLib.spawn_command_line_sync(`iw dev ${interfaceName} link`);
|
||||||
|
if (success && stdout) {
|
||||||
|
return parseIwLinkOutput(new TextDecoder().decode(stdout));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// iw not available - graceful degradation
|
||||||
|
}
|
||||||
|
|
||||||
|
return createEmptyIwLinkInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeSsid(ssidBytes: GLib.Bytes | null): string | null {
|
||||||
|
if (!ssidBytes) return null;
|
||||||
|
const data = ssidBytes.get_data();
|
||||||
|
return data ? new TextDecoder().decode(data) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateSignalDbm(strengthPercent: number): SignalDbm {
|
||||||
|
const MIN_DBM = -90;
|
||||||
|
const MAX_DBM = -30;
|
||||||
|
return asSignalDbm(MIN_DBM + (strengthPercent / 100) * (MAX_DBM - MIN_DBM));
|
||||||
|
}
|
||||||
|
|
||||||
|
function frequencyToChannel(frequency: FrequencyMHz): ChannelNumber {
|
||||||
|
const freq = frequency as number;
|
||||||
|
|
||||||
|
if (freq >= 2412 && freq <= 2484) {
|
||||||
|
if (freq === 2484) return asChannelNumber(14);
|
||||||
|
return asChannelNumber(Math.round((freq - 2412) / 5) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freq >= 5170 && freq <= 5825) {
|
||||||
|
return asChannelNumber(Math.round((freq - 5000) / 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freq >= 5955 && freq <= 7115) {
|
||||||
|
return asChannelNumber(Math.round((freq - 5950) / 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
return asChannelNumber(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frequencyToBand(frequency: FrequencyMHz): FrequencyBand {
|
||||||
|
const freq = frequency as number;
|
||||||
|
|
||||||
|
if (freq >= 2400 && freq < 2500) return '2.4 GHz';
|
||||||
|
if (freq >= 5150 && freq < 5900) return '5 GHz';
|
||||||
|
if (freq >= 5925 && freq <= 7125) return '6 GHz';
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const AP_SECURITY = {
|
||||||
|
NONE: 0x0,
|
||||||
|
KEY_MGMT_PSK: 0x100,
|
||||||
|
KEY_MGMT_802_1X: 0x200,
|
||||||
|
KEY_MGMT_SAE: 0x400,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function getSecurityProtocol(ap: NM.AccessPoint): SecurityProtocol {
|
||||||
|
const wpaFlags = ap.get_wpa_flags();
|
||||||
|
const rsnFlags = ap.get_rsn_flags();
|
||||||
|
|
||||||
|
const protocols = detectSecurityProtocols(wpaFlags, rsnFlags);
|
||||||
|
|
||||||
|
if (protocols.length === 0) {
|
||||||
|
const isOpen = wpaFlags === AP_SECURITY.NONE && rsnFlags === AP_SECURITY.NONE;
|
||||||
|
return isOpen ? 'Open' : 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return protocols[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectSecurityProtocols(wpaFlags: number, rsnFlags: number): SecurityProtocol[] {
|
||||||
|
const protocols: SecurityProtocol[] = [];
|
||||||
|
|
||||||
|
if (rsnFlags & AP_SECURITY.KEY_MGMT_SAE) {
|
||||||
|
protocols.push('WPA3');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rsnFlags & AP_SECURITY.KEY_MGMT_802_1X) {
|
||||||
|
protocols.push('WPA2-Enterprise');
|
||||||
|
} else if (rsnFlags & AP_SECURITY.KEY_MGMT_PSK) {
|
||||||
|
protocols.push('WPA2');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wpaFlags & AP_SECURITY.KEY_MGMT_802_1X && !protocols.includes('WPA2-Enterprise')) {
|
||||||
|
protocols.push('WPA-Enterprise');
|
||||||
|
} else if (wpaFlags & AP_SECURITY.KEY_MGMT_PSK) {
|
||||||
|
protocols.push('WPA');
|
||||||
|
}
|
||||||
|
|
||||||
|
return protocols;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSignalQuality(signalStrength: SignalDbm | null): SignalQuality {
|
||||||
|
if (signalStrength === null) return 'Unknown';
|
||||||
|
|
||||||
|
const dbm = signalStrength as number;
|
||||||
|
if (dbm >= SIGNAL_THRESHOLDS.Excellent) return 'Excellent';
|
||||||
|
if (dbm >= SIGNAL_THRESHOLDS.Good) return 'Good';
|
||||||
|
if (dbm >= SIGNAL_THRESHOLDS.Fair) return 'Fair';
|
||||||
|
if (dbm >= SIGNAL_THRESHOLDS.Weak) return 'Weak';
|
||||||
|
return 'Poor';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSignalCssClass(signalStrength: SignalDbm | null): SignalCssClass {
|
||||||
|
const quality = getSignalQuality(signalStrength);
|
||||||
|
|
||||||
|
const cssClassMap: Record<SignalQuality, SignalCssClass> = {
|
||||||
|
Excellent: 'wifi-signal-excellent',
|
||||||
|
Good: 'wifi-signal-good',
|
||||||
|
Fair: 'wifi-signal-fair',
|
||||||
|
Weak: 'wifi-signal-weak',
|
||||||
|
Poor: 'wifi-signal-poor',
|
||||||
|
Unknown: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return cssClassMap[quality];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatValue<T>(value: T | null, formatter?: (v: T) => string): string {
|
||||||
|
if (value === null) return PLACEHOLDER;
|
||||||
|
return formatter ? formatter(value) : String(value);
|
||||||
|
}
|
||||||
95
stylesheet.css
Normal file
95
stylesheet.css
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
/* WiFi Signal Plus - Stylesheet */
|
||||||
|
|
||||||
|
/* Panel indicator */
|
||||||
|
.wifi-signal-plus-indicator {
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-signal-plus-label {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generation colors */
|
||||||
|
.wifi-gen-4 {
|
||||||
|
color: #888888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-gen-5 {
|
||||||
|
color: #3584e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-gen-6 {
|
||||||
|
color: #33d17a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-gen-7 {
|
||||||
|
color: #9141ac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-disconnected {
|
||||||
|
color: #c0bfbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip popup */
|
||||||
|
.wifi-signal-plus-popup {
|
||||||
|
padding: 12px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-popup-section {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-popup-section:not(:last-child) {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-popup-header {
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-popup-ssid {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-popup-generation {
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-popup-row {
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-popup-label {
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-popup-value {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signal strength indicator */
|
||||||
|
.wifi-signal-excellent {
|
||||||
|
color: #33d17a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-signal-good {
|
||||||
|
color: #8ff0a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-signal-fair {
|
||||||
|
color: #f6d32d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-signal-weak {
|
||||||
|
color: #ff7800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wifi-signal-poor {
|
||||||
|
color: #e01b24;
|
||||||
|
}
|
||||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"declaration": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"verbatimModuleSyntax": false,
|
||||||
|
"paths": {
|
||||||
|
"gi://*": ["./node_modules/@girs/*"],
|
||||||
|
"resource://*": ["./node_modules/@girs/gnome-shell/src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'html'],
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: ['src/**/*.test.ts', 'src/ambient.d.ts'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue