From 60d39955b0f06973c6b4fd3aed63a3b238b2c015 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 22:19:23 +0000 Subject: [PATCH] Add mobile app (PWA) and full network-stack backend Introduce a mobile-friendly interface and extend the backend to expose the entire network stack from physical interfaces up to GNSS and satellite networks. - network_extended.py: ExtendedNetworkMonitor probing cellular (2G-5G via ModemManager), Wi-Fi (nmcli/iw), satellite internet (Starlink dish gRPC) and GNSS/satellite positioning (gpsd), each with graceful fallbacks. - api_server.py: Flask REST API exposing the full backend and serving the PWA; aggregated /api/overview and /api/extended/* endpoints. - mobile/: installable PWA (manifest, service worker, icon) with Dashboard, Layers, GNSS and Tools views; responsive dark UI, network-first API, offline app shell. - run_mobile.sh/.bat launchers, [Mobile] config section, flask dependency. - Tests for the extended layers and README documentation. --- README.md | 84 ++++++++- api_server.py | 199 ++++++++++++++++++++ config.ini | 6 + mobile/app.js | 315 +++++++++++++++++++++++++++++++ mobile/icon.svg | 25 +++ mobile/index.html | 93 ++++++++++ mobile/manifest.webmanifest | 19 ++ mobile/service-worker.js | 57 ++++++ mobile/styles.css | 146 +++++++++++++++ network_extended.py | 358 ++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + run_mobile.bat | 14 ++ run_mobile.sh | 20 ++ test_networkzero.py | 59 ++++++ 14 files changed, 1393 insertions(+), 5 deletions(-) create mode 100644 api_server.py create mode 100644 mobile/app.js create mode 100644 mobile/icon.svg create mode 100644 mobile/index.html create mode 100644 mobile/manifest.webmanifest create mode 100644 mobile/service-worker.js create mode 100644 mobile/styles.css create mode 100644 network_extended.py create mode 100644 run_mobile.bat create mode 100755 run_mobile.sh diff --git a/README.md b/README.md index b2adab8..ddf2bef 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,11 @@ > **Ionity (Pty) Ltd** — [www.ionity.today](https://www.ionity.today) -A cross-platform, Python-based network monitoring tool with a full **GUI** and a rich **CLI**. -Monitor pings, DNS, active ports, interface traffic and Pi-hole statistics — all from one place. +A cross-platform, Python-based network monitoring tool with a full **GUI**, a rich **CLI**, +and an installable **mobile app (PWA)**. Monitor pings, DNS, active ports, interface traffic +and Pi-hole statistics — plus the *full network stack* from physical interfaces all the way up +to **cellular (2G–5G)**, **Wi-Fi**, **satellite internet (Starlink)** and **GNSS / satellite +positioning** — all from one place, including from your phone. --- @@ -21,6 +24,25 @@ Monitor pings, DNS, active ports, interface traffic and Pi-hole statistics — a | Pi-hole status / summary / top blocked | ✓ | ✓ | | Live continuous monitoring | ✓ | ✓ | +### 📱 Mobile app — extended network stack + +A separate **mobile PWA** (served by the built-in API server) adds full-stack +visibility you can open on any phone: + +| Layer | Source | Notes | +|---|---|---| +| Host / interfaces / IP / traffic | psutil | Same data as desktop | +| Internet connectivity & quality | ping | Live dashboard | +| **Cellular (2G/3G/4G/5G)** | ModemManager (`mmcli`) | Operator, generation, signal | +| **Wi-Fi link** | `nmcli` / `iw` | SSID, signal, band, rate | +| **Satellite internet (Starlink)** | Dish gRPC @ `192.168.100.1` | Latency, throughput, obstruction | +| **GNSS / satellite positioning** | `gpsd` | Fix, lat/lon, satellites in view (GPS/Galileo/GLONASS/BeiDou) | +| Ping / DNS / port tools | core engine | Interactive, on-device | + +Each extended layer degrades gracefully: when the hardware or helper tool +isn't present, the app shows a clear "unavailable" state with the reason +instead of failing. + --- ## 🚀 Quick Start @@ -65,6 +87,49 @@ run_gui.bat # Windows run_cli.bat --help # Windows ``` +**📱 Mobile app (PWA):** +```bash +./run_mobile.sh # Unix +run_mobile.bat # Windows +``` +Then, on your phone (connected to the same network), open the printed URL +(e.g. `http://192.168.1.50:8088`) in your browser and choose **"Add to Home +Screen"** to install it as an app. The server binds to `0.0.0.0:8088` by +default; override with `NZM_HOST` / `NZM_PORT` or the `[Mobile]` section of +`config.ini`. + +--- + +## 📡 Mobile REST API + +The mobile app is backed by a small Flask API (`api_server.py`). The same +endpoints can be consumed by any client: + +| Endpoint | Description | +|---|---| +| `GET /api/overview` | Everything for the dashboard in one call | +| `GET /api/network` | Hostname, IPs, interfaces | +| `GET /api/connectivity` | Internet reachability & quality | +| `GET /api/traffic` | Per-interface traffic counters | +| `GET /api/ping?host=&count=` | Ping a host | +| `GET /api/dns?domain=&server=` | DNS lookup | +| `GET /api/ports?host=&ports=` | Port scan | +| `GET /api/pihole/` | Pi-hole status / summary / blocked | +| `GET /api/extended/cellular` | Mobile broadband (2G–5G) | +| `GET /api/extended/wifi` | Wi-Fi link details | +| `GET /api/extended/satellite` | Satellite internet (Starlink) | +| `GET /api/extended/gnss` | GNSS / satellite positioning | +| `GET /api/extended/all` | Consolidated multi-layer snapshot | + +### Optional helper tools (for full extended data) + +These are auto-detected; install only the layers you need: + +- **Cellular:** `ModemManager` (provides `mmcli`) +- **Wi-Fi:** `network-manager` (provides `nmcli`) +- **Satellite internet:** `grpcurl` (for live Starlink telemetry) +- **GNSS:** `gpsd` + a connected GNSS/GPS receiver + --- ## 📟 CLI Reference @@ -181,15 +246,24 @@ python test_networkzero.py --demo ``` NetworkzeroMonitor/ ├── network_monitor.py # Core monitoring engine +├── network_extended.py # Extended layers: cellular, wifi, satellite, GNSS ├── networkzero_cli.py # Command-line interface ├── networkzero_gui.py # Graphical user interface (tkinter) +├── api_server.py # Mobile REST API + PWA server (Flask) +├── mobile/ # Installable mobile PWA front-end +│ ├── index.html +│ ├── styles.css +│ ├── app.js +│ ├── manifest.webmanifest +│ ├── service-worker.js +│ └── icon.svg ├── test_networkzero.py # Unit + integration tests ├── config.ini # Application configuration ├── requirements.txt # Python dependencies -├── setup.sh # Unix setup script -├── setup.bat # Windows setup script +├── setup.sh / setup.bat # Setup scripts ├── run_cli.sh / run_cli.bat -└── run_gui.sh / run_gui.bat +├── run_gui.sh / run_gui.bat +└── run_mobile.sh / run_mobile.bat ``` --- diff --git a/api_server.py b/api_server.py new file mode 100644 index 0000000..b73d1ee --- /dev/null +++ b/api_server.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +NetworkzeroMonitor - Mobile API Server +Ionity (Pty) Ltd - www.ionity.today + +A lightweight Flask REST API that exposes the full NetworkzeroMonitor +backend over HTTP and serves the installable mobile PWA. Point any +phone's browser at this server (http://:8088) to monitor the +network from a handset, or "Add to Home Screen" to install it as an app. + +Endpoints (all return JSON unless noted): + GET / -> mobile PWA (HTML) + GET /api/health -> server liveness + version + GET /api/network -> hostname, IPs, interfaces + GET /api/connectivity -> internet reachability + quality + GET /api/traffic -> per-interface traffic counters + GET /api/ping?host=&count= -> ping a host + GET /api/dns?domain=&server= -> DNS lookup + GET /api/ports?host=&ports= -> port scan + GET /api/pihole/ -> pihole status|summary|blocked (query: url, api_key) + GET /api/extended/cellular -> mobile broadband (2G-5G) + GET /api/extended/wifi -> Wi-Fi link details + GET /api/extended/satellite -> satellite internet (Starlink) + GET /api/extended/gnss -> GNSS / satellite positioning + GET /api/extended/all -> consolidated multi-layer snapshot + GET /api/overview -> everything in one call (mobile dashboard) +""" + +import os +import configparser + +from flask import Flask, jsonify, request, send_from_directory + +from network_monitor import NetworkMonitor, PiHoleMonitor +from network_extended import ExtendedNetworkMonitor + +APP_VERSION = '1.1.0' +MOBILE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mobile') + +app = Flask(__name__, static_folder=None) + +_monitor = NetworkMonitor() +_extended = ExtendedNetworkMonitor() + +# Load config for sensible server + pihole defaults. +_config = configparser.ConfigParser() +_config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.ini') +if os.path.exists(_config_path): + _config.read(_config_path) + + +def _cfg(section: str, option: str, fallback=None): + if _config.has_option(section, option): + return _config.get(section, option) + return fallback + + +# --------------------------------------------------------------------------- # +# Static / PWA routes +# --------------------------------------------------------------------------- # +@app.route('/') +def index(): + return send_from_directory(MOBILE_DIR, 'index.html') + + +@app.route('/') +def static_files(filename): + # Serve the PWA assets (manifest, service worker, icons, css, js). + return send_from_directory(MOBILE_DIR, filename) + + +# --------------------------------------------------------------------------- # +# Core API +# --------------------------------------------------------------------------- # +@app.route('/api/health') +def health(): + return jsonify({ + 'status': 'ok', + 'app': 'NetworkzeroMonitor', + 'version': APP_VERSION, + 'company': 'Ionity (Pty) Ltd', + 'website': 'www.ionity.today', + }) + + +@app.route('/api/network') +def network(): + return jsonify(_monitor.get_network_info()) + + +@app.route('/api/connectivity') +def connectivity(): + return jsonify(_monitor.check_internet_connectivity()) + + +@app.route('/api/traffic') +def traffic(): + return jsonify({'interfaces': _monitor.get_interface_traffic()}) + + +@app.route('/api/ping') +def ping(): + host = request.args.get('host', _cfg('Network', 'default_ping_host', '8.8.8.8')) + count = request.args.get('count', default=4, type=int) + timeout = request.args.get('timeout', default=2, type=int) + return jsonify(_monitor.ping_host(host, count=count, timeout=timeout)) + + +@app.route('/api/dns') +def dns(): + domain = request.args.get('domain') + if not domain: + return jsonify({'success': False, 'error': 'domain query parameter required'}), 400 + server = request.args.get('server') + return jsonify(_monitor.check_dns_resolution(domain, server)) + + +@app.route('/api/ports') +def ports(): + host = request.args.get('host', '127.0.0.1') + ports_arg = request.args.get('ports') + port_list = None + if ports_arg: + try: + port_list = [int(p) for p in ports_arg.replace(',', ' ').split()] + except ValueError: + return jsonify({'error': 'ports must be integers'}), 400 + return jsonify(_monitor.get_open_ports(host=host, ports=port_list)) + + +@app.route('/api/pihole/') +def pihole(action): + if action not in ('status', 'summary', 'blocked'): + return jsonify({'success': False, 'error': 'unknown action'}), 404 + url = request.args.get('url', _cfg('PiHole', 'url')) + if not url: + return jsonify({'success': False, 'error': 'pihole url not configured'}), 400 + api_key = request.args.get('api_key', _cfg('PiHole', 'api_key')) + pi = PiHoleMonitor(url, api_key) + if action == 'status': + return jsonify(pi.check_status()) + if action == 'summary': + return jsonify(pi.get_summary()) + count = request.args.get('count', default=10, type=int) + return jsonify(pi.get_top_blocked(count=count)) + + +# --------------------------------------------------------------------------- # +# Extended network layers (cellular, wifi, satellite internet, GNSS) +# --------------------------------------------------------------------------- # +@app.route('/api/extended/cellular') +def ext_cellular(): + return jsonify(_extended.get_cellular_info()) + + +@app.route('/api/extended/wifi') +def ext_wifi(): + return jsonify(_extended.get_wifi_info()) + + +@app.route('/api/extended/satellite') +def ext_satellite(): + return jsonify(_extended.get_satellite_internet()) + + +@app.route('/api/extended/gnss') +def ext_gnss(): + return jsonify(_extended.get_gnss_info()) + + +@app.route('/api/extended/all') +def ext_all(): + return jsonify(_extended.get_all_layers()) + + +# --------------------------------------------------------------------------- # +# Aggregated dashboard payload (single round-trip for the mobile home screen) +# --------------------------------------------------------------------------- # +@app.route('/api/overview') +def overview(): + return jsonify({ + 'network': _monitor.get_network_info(), + 'connectivity': _monitor.check_internet_connectivity(), + 'extended': _extended.get_all_layers(), + 'version': APP_VERSION, + }) + + +def main(): + host = os.environ.get('NZM_HOST', '0.0.0.0') + port = int(os.environ.get('NZM_PORT', '8088')) + print("NetworkzeroMonitor - Mobile API Server") + print("Ionity (Pty) Ltd - www.ionity.today") + print(f"Serving on http://{host}:{port} (open this on your phone)") + app.run(host=host, port=port, threaded=True) + + +if __name__ == '__main__': + main() diff --git a/config.ini b/config.ini index 30fac07..4c63923 100644 --- a/config.ini +++ b/config.ini @@ -27,6 +27,12 @@ ping_timeout = 2 # url = http://192.168.1.1 # api_key = your_api_key_here +[Mobile] +# Mobile API server bind address and port. +# Use 0.0.0.0 so phones on the same network can reach it. +host = 0.0.0.0 +port = 8088 + [UI] # GUI window size window_width = 1024 diff --git a/mobile/app.js b/mobile/app.js new file mode 100644 index 0000000..ed81822 --- /dev/null +++ b/mobile/app.js @@ -0,0 +1,315 @@ +/* + * NetworkzeroMonitor - Mobile PWA logic + * Ionity (Pty) Ltd - www.ionity.today + * + * Talks to the Flask API on the same origin, renders the full network + * stack (interfaces -> connectivity -> cellular/wifi -> satellite -> GNSS) + * and provides interactive ping / DNS / port tools. + */ + +const api = (path) => fetch(path, { headers: { 'Accept': 'application/json' } }).then((r) => r.json()); + +const $ = (sel) => document.querySelector(sel); +const el = (tag, cls, html) => { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (html !== undefined) n.innerHTML = html; + return n; +}; + +function fmtBytes(n) { + if (n == null) return '—'; + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return `${n.toFixed(1)} ${units[i]}`; +} + +function card(title, pill) { + const c = el('div', 'card'); + const h = el('h2', null, title); + if (pill) { + const p = el('span', `pill ${pill.cls || ''}`, pill.text); + h.appendChild(p); + } + c.appendChild(h); + return c; +} + +function kv(parent, k, v) { + const row = el('div', 'kv'); + row.appendChild(el('span', 'k', k)); + row.appendChild(el('span', 'v', v == null || v === '' ? '—' : String(v))); + parent.appendChild(row); +} + +/* ----------------------------- Tab switching ---------------------------- */ +document.querySelectorAll('.tab').forEach((tab) => { + tab.addEventListener('click', () => { + document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active')); + document.querySelectorAll('.view').forEach((v) => v.classList.remove('active')); + tab.classList.add('active'); + $(`#view-${tab.dataset.view}`).classList.add('active'); + }); +}); + +/* ------------------------------- Dashboard ------------------------------ */ +async function loadDashboard() { + const grid = $('#dashboard-grid'); + grid.innerHTML = ''; + let data; + try { + data = await api('/api/overview'); + } catch (e) { + setStatus('bad', 'Cannot reach NetworkzeroMonitor server'); + return; + } + + const conn = data.connectivity || {}; + const qual = conn.quality || 'Unknown'; + const cls = !conn.connected ? 'bad' : (qual === 'Excellent' ? 'ok' : 'warn'); + setStatus(cls, `Internet: ${conn.connected ? qual : 'No Connection'}`); + + // Network info card + const net = data.network || {}; + const c1 = card('🖥 Host & Network'); + kv(c1, 'Hostname', net.hostname); + kv(c1, 'Local IP', net.local_ip); + kv(c1, 'Public IP', net.public_ip); + kv(c1, 'Platform', net.platform); + grid.appendChild(c1); + + // Connectivity card + const c2 = card('🌐 Connectivity', { cls, text: qual }); + (conn.tests || []).forEach((t) => kv(c2, t.domain, t.reachable ? '✓ reachable' : '✗ down')); + grid.appendChild(c2); + + // Quick WAN summary from extended layers + const ext = data.extended || {}; + grid.appendChild(summaryCellular(ext.cellular)); + grid.appendChild(summarySatellite(ext.satellite_internet)); + grid.appendChild(summaryGnss(ext.gnss)); + + $('#foot-meta').textContent = `NetworkzeroMonitor v${data.version || ''} · ${net.timestamp || ''}`; +} + +function summaryCellular(cell) { + if (!cell || !cell.available) { + const c = card('📶 Cellular', { cls: 'warn', text: 'n/a' }); + c.appendChild(el('div', 'muted', (cell && cell.reason) || 'No cellular modem')); + return c; + } + const m = cell.modems[0] || {}; + const c = card('📶 Cellular', { cls: 'ok', text: m.generation || '—' }); + kv(c, 'Operator', m.operator); + kv(c, 'Generation', m.generation); + kv(c, 'Signal', m.signal_percent != null ? `${m.signal_percent}%` : '—'); + kv(c, 'State', m.state); + return c; +} + +function summarySatellite(sat) { + if (!sat) return card('🛰 Satellite Internet', { cls: 'warn', text: 'n/a' }); + if (!sat.available) { + const c = card('🛰 Satellite Internet', { cls: 'warn', text: 'n/a' }); + c.appendChild(el('div', 'muted', sat.reason || 'No satellite link')); + return c; + } + const c = card('🛰 Satellite Internet', { cls: 'ok', text: sat.provider || 'link' }); + kv(c, 'Provider', sat.provider); + kv(c, 'Dish reachable', sat.dish_reachable ? 'yes' : 'no'); + if (sat.ping_latency_ms != null) kv(c, 'Latency', `${sat.ping_latency_ms.toFixed?.(1) ?? sat.ping_latency_ms} ms`); + if (sat.downlink_bps != null) kv(c, 'Downlink', `${fmtBytes(sat.downlink_bps)}/s`); + if (sat.obstruction_fraction != null) kv(c, 'Obstruction', `${(sat.obstruction_fraction * 100).toFixed(2)}%`); + return c; +} + +function summaryGnss(gnss) { + if (!gnss || !gnss.available) { + const c = card('📡 GNSS Positioning', { cls: 'warn', text: 'n/a' }); + c.appendChild(el('div', 'muted', (gnss && gnss.reason) || 'No GNSS receiver')); + return c; + } + const fix = gnss.fix || {}; + const sats = gnss.satellites || {}; + const c = card('📡 GNSS Positioning', { cls: 'ok', text: fix.mode || 'fix' }); + kv(c, 'Fix', fix.mode); + kv(c, 'Latitude', fix.latitude); + kv(c, 'Longitude', fix.longitude); + kv(c, 'Satellites', `${sats.used ?? 0}/${sats.in_view ?? 0} used`); + return c; +} + +/* ------------------------------ Layers view ----------------------------- */ +async function loadLayers() { + const grid = $('#layers-grid'); + grid.innerHTML = '
Loading layers…
'; + let layers, traffic; + try { + [layers, traffic] = await Promise.all([api('/api/extended/all'), api('/api/traffic')]); + } catch (e) { + grid.innerHTML = '
Failed to load network layers.
'; + return; + } + grid.innerHTML = ''; + + // Wi-Fi + const wifi = layers.wifi || {}; + if (wifi.available && wifi.networks.length) { + const w = wifi.networks[0]; + const c = card('📡 Wi-Fi Link', { cls: 'ok', text: w.band }); + kv(c, 'SSID', w.ssid); + kv(c, 'Signal', w.signal_percent != null ? `${w.signal_percent}%` : '—'); + kv(c, 'Band', w.band); + kv(c, 'Frequency', w.frequency_mhz ? `${w.frequency_mhz} MHz` : '—'); + kv(c, 'Rate', w.rate); + kv(c, 'Security', w.security); + grid.appendChild(c); + } else { + const c = card('📡 Wi-Fi Link', { cls: 'warn', text: 'n/a' }); + c.appendChild(el('div', 'muted', wifi.reason || 'No active Wi-Fi')); + grid.appendChild(c); + } + + // Cellular (full) + grid.appendChild(summaryCellular(layers.cellular)); + // Satellite internet (full) + grid.appendChild(summarySatellite(layers.satellite_internet)); + + // Per-interface traffic + const ifaces = (traffic && traffic.interfaces) || {}; + Object.keys(ifaces).forEach((name) => { + const d = ifaces[name]; + const c = card(`🔌 ${name}`); + kv(c, 'Sent', fmtBytes(d.bytes_sent)); + kv(c, 'Received', fmtBytes(d.bytes_recv)); + kv(c, 'Packets out/in', `${d.packets_sent} / ${d.packets_recv}`); + kv(c, 'Errors out/in', `${d.errout} / ${d.errin}`); + kv(c, 'Drops out/in', `${d.dropout} / ${d.dropin}`); + grid.appendChild(c); + }); +} + +/* ------------------------------- GNSS view ------------------------------ */ +async function loadGnss() { + const panel = $('#gnss-panel'); + panel.innerHTML = '
Querying GNSS receiver…
'; + let gnss; + try { + gnss = await api('/api/extended/gnss'); + } catch (e) { + panel.innerHTML = '
Failed to query GNSS.
'; + return; + } + panel.innerHTML = ''; + + if (!gnss.available) { + const c = card('📡 GNSS / Satellite Positioning', { cls: 'warn', text: 'unavailable' }); + c.appendChild(el('div', 'muted', gnss.reason || 'No GNSS receiver detected.')); + c.appendChild(el('div', 'muted', 'Connect a GNSS receiver and run gpsd to view live fix and satellites.')); + panel.appendChild(c); + return; + } + + const fix = gnss.fix || {}; + const cFix = card('🛰 Position Fix', { cls: 'ok', text: fix.mode || 'fix' }); + kv(cFix, 'Fix mode', fix.mode); + kv(cFix, 'Latitude', fix.latitude); + kv(cFix, 'Longitude', fix.longitude); + kv(cFix, 'Altitude', fix.altitude_m != null ? `${fix.altitude_m} m` : '—'); + kv(cFix, 'Speed', fix.speed_mps != null ? `${fix.speed_mps} m/s` : '—'); + kv(cFix, 'UTC time', fix.time); + panel.appendChild(cFix); + + const sats = gnss.satellites || {}; + const cSat = card('📡 Satellites', { cls: 'ok', text: `${sats.used ?? 0}/${sats.in_view ?? 0}` }); + kv(cSat, 'In view', sats.in_view); + kv(cSat, 'Used in fix', sats.used); + kv(cSat, 'HDOP / VDOP', `${sats.hdop ?? '—'} / ${sats.vdop ?? '—'}`); + + const detail = sats.detail || []; + if (detail.length) { + const table = el('table', 'sat-table'); + table.innerHTML = 'PRNSystemElevSNRUsed'; + const tb = el('tbody'); + detail.sort((a, b) => (b.snr || 0) - (a.snr || 0)).forEach((s) => { + const tr = el('tr'); + tr.innerHTML = + `${s.prn ?? '—'}` + + `${s.constellation ?? '—'}` + + `${s.elevation ?? '—'}°` + + `${s.snr ?? '—'}` + + `${s.used ? '✓' : ''}`; + tb.appendChild(tr); + }); + table.appendChild(tb); + cSat.appendChild(table); + } + panel.appendChild(cSat); +} + +/* ------------------------------- Tools ---------------------------------- */ +document.querySelectorAll('[data-tool]').forEach((btn) => { + btn.addEventListener('click', () => runTool(btn.dataset.tool)); +}); + +async function runTool(tool) { + if (tool === 'ping') { + const host = encodeURIComponent($('#ping-host').value.trim()); + show('#ping-out', 'Pinging…'); + const r = await api(`/api/ping?host=${host}`); + show('#ping-out', JSON.stringify(r, null, 2)); + } else if (tool === 'dns') { + const domain = encodeURIComponent($('#dns-domain').value.trim()); + const server = encodeURIComponent($('#dns-server').value.trim()); + show('#dns-out', 'Resolving…'); + const r = await api(`/api/dns?domain=${domain}${server ? `&server=${server}` : ''}`); + show('#dns-out', JSON.stringify(r, null, 2)); + } else if (tool === 'ports') { + const host = encodeURIComponent($('#ports-host').value.trim()); + const ports = encodeURIComponent($('#ports-list').value.trim()); + show('#ports-out', 'Scanning…'); + const r = await api(`/api/ports?host=${host}${ports ? `&ports=${ports}` : ''}`); + show('#ports-out', JSON.stringify(r, null, 2)); + } +} + +function show(sel, text) { $(sel).textContent = text; } + +/* ------------------------------- Status --------------------------------- */ +function setStatus(cls, text) { + $('#status-dot').className = `dot ${cls}`; + $('#status-text').textContent = text; +} + +/* ----------------------------- Orchestration ---------------------------- */ +function refreshActive() { + const view = document.querySelector('.tab.active').dataset.view; + if (view === 'dashboard') return loadDashboard(); + if (view === 'layers') return loadLayers(); + if (view === 'gnss') return loadGnss(); +} + +$('#refresh').addEventListener('click', async () => { + const btn = $('#refresh'); + btn.classList.add('spin'); + try { await refreshActive(); } finally { btn.classList.remove('spin'); } +}); + +// Re-load a view's data the first time its tab is opened. +document.querySelectorAll('.tab').forEach((tab) => { + tab.addEventListener('click', () => refreshActive()); +}); + +// Register service worker for offline shell + installability. +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('service-worker.js').catch(() => {}); + }); +} + +// Initial load + light auto-refresh of the dashboard. +loadDashboard(); +setInterval(() => { + if (document.querySelector('.tab.active').dataset.view === 'dashboard') loadDashboard(); +}, 15000); diff --git a/mobile/icon.svg b/mobile/icon.svg new file mode 100644 index 0000000..c26f083 --- /dev/null +++ b/mobile/icon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/index.html b/mobile/index.html new file mode 100644 index 0000000..41bae7d --- /dev/null +++ b/mobile/index.html @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + NetworkzeroMonitor + + +
+
+ +
+

NetworkzeroMonitor

+ Ionity (Pty) Ltd · www.ionity.today +
+
+ +
+ + + +
+ +
+
+ + Connecting… +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+

Ping

+
+ + +
+

+      
+
+

DNS Lookup

+
+ + + +
+

+      
+
+

Port Scan

+
+ + + +
+

+      
+
+
+ +
+ NetworkzeroMonitor +
+ + + + diff --git a/mobile/manifest.webmanifest b/mobile/manifest.webmanifest new file mode 100644 index 0000000..283b4ce --- /dev/null +++ b/mobile/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "NetworkzeroMonitor", + "short_name": "NetzeroMon", + "description": "Full-stack network monitoring from interfaces to GNSS and satellite. Ionity (Pty) Ltd.", + "start_url": ".", + "scope": ".", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0b1020", + "theme_color": "#0b1020", + "icons": [ + { + "src": "icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/mobile/service-worker.js b/mobile/service-worker.js new file mode 100644 index 0000000..0dbbdd1 --- /dev/null +++ b/mobile/service-worker.js @@ -0,0 +1,57 @@ +/* + * NetworkzeroMonitor - Service Worker + * Ionity (Pty) Ltd - www.ionity.today + * + * Caches the app shell for offline launch / installability. Live API + * calls (/api/*) are always network-first so monitoring data stays fresh. + */ + +const CACHE = 'nzm-shell-v1'; +const SHELL = [ + '.', + 'index.html', + 'styles.css', + 'app.js', + 'icon.svg', + 'manifest.webmanifest', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // API requests: network-first (monitoring data must be live). + if (url.pathname.startsWith('/api/')) { + event.respondWith(fetch(event.request).catch(() => + new Response(JSON.stringify({ error: 'offline' }), { + headers: { 'Content-Type': 'application/json' }, + status: 503, + }) + )); + return; + } + + // App shell: cache-first with background refresh. + event.respondWith( + caches.match(event.request).then((cached) => + cached || fetch(event.request).then((resp) => { + const copy = resp.clone(); + caches.open(CACHE).then((cache) => cache.put(event.request, copy)).catch(() => {}); + return resp; + }).catch(() => cached) + ) + ); +}); diff --git a/mobile/styles.css b/mobile/styles.css new file mode 100644 index 0000000..72aa69e --- /dev/null +++ b/mobile/styles.css @@ -0,0 +1,146 @@ +/* + * NetworkzeroMonitor - Mobile PWA styles + * Ionity (Pty) Ltd - www.ionity.today + * Mobile-first, dark theme, safe-area aware. + */ + +:root { + --bg: #0b1020; + --bg-elev: #141b30; + --bg-card: #182238; + --line: #243150; + --text: #e8edf7; + --muted: #94a3c4; + --accent: #4f8cff; + --ok: #38d39f; + --warn: #f5b14c; + --bad: #ff5d6c; + --radius: 14px; +} + +* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 15px; + line-height: 1.45; +} + +/* App bar */ +.app-bar { + position: sticky; top: 0; z-index: 10; + display: flex; align-items: center; justify-content: space-between; + gap: 12px; + padding: calc(env(safe-area-inset-top) + 12px) 16px 12px; + background: linear-gradient(180deg, #101933, #0b1020); + border-bottom: 1px solid var(--line); +} +.brand { display: flex; align-items: center; gap: 10px; } +.brand-logo { width: 34px; height: 34px; } +.app-bar h1 { font-size: 16px; margin: 0; font-weight: 700; letter-spacing: .2px; } +.app-bar .sub { font-size: 11px; color: var(--muted); } +.icon-btn { + background: var(--bg-card); color: var(--text); + border: 1px solid var(--line); border-radius: 10px; + width: 40px; height: 40px; font-size: 20px; cursor: pointer; +} +.icon-btn:active { transform: scale(.94); } + +/* Tabs */ +.tabs { + position: sticky; top: 0; z-index: 9; + display: flex; gap: 6px; padding: 10px 12px; + background: var(--bg); border-bottom: 1px solid var(--line); + overflow-x: auto; +} +.tab { + flex: 1 0 auto; white-space: nowrap; + background: var(--bg-card); color: var(--muted); + border: 1px solid var(--line); border-radius: 999px; + padding: 8px 16px; font-size: 13px; font-weight: 600; cursor: pointer; +} +.tab.active { background: var(--accent); color: #fff; border-color: var(--accent); } + +/* Views */ +main { padding: 14px 12px calc(env(safe-area-inset-bottom) + 70px); } +.view { display: none; } +.view.active { display: block; } + +/* Status banner */ +.status-banner { + display: flex; align-items: center; gap: 10px; + background: var(--bg-elev); border: 1px solid var(--line); + border-radius: var(--radius); padding: 14px 16px; margin-bottom: 14px; + font-weight: 600; +} +.dot { width: 12px; height: 12px; border-radius: 50%; background: var(--muted); } +.dot.ok { background: var(--ok); box-shadow: 0 0 10px var(--ok); } +.dot.warn { background: var(--warn); box-shadow: 0 0 10px var(--warn); } +.dot.bad { background: var(--bad); box-shadow: 0 0 10px var(--bad); } + +/* Cards + grid */ +.grid { display: grid; grid-template-columns: 1fr; gap: 12px; } +@media (min-width: 560px) { .grid { grid-template-columns: 1fr 1fr; } } + +.card { + background: var(--bg-card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 14px 16px; +} +.card h2 { + margin: 0 0 10px; font-size: 14px; font-weight: 700; + display: flex; align-items: center; gap: 8px; +} +.card .pill { + margin-left: auto; font-size: 11px; font-weight: 700; + padding: 3px 9px; border-radius: 999px; background: var(--bg-elev); color: var(--muted); +} +.pill.ok { background: rgba(56,211,159,.15); color: var(--ok); } +.pill.warn { background: rgba(245,177,76,.15); color: var(--warn); } +.pill.bad { background: rgba(255,93,108,.15); color: var(--bad); } + +.kv { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; font-size: 13px; } +.kv .k { color: var(--muted); } +.kv .v { text-align: right; word-break: break-word; font-variant-numeric: tabular-nums; } +.kv + .kv { border-top: 1px dashed rgba(255,255,255,.04); } + +.muted { color: var(--muted); font-size: 13px; } + +/* Satellite list */ +.sat-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; } +.sat-table th, .sat-table td { text-align: left; padding: 6px 6px; border-bottom: 1px solid var(--line); } +.sat-table th { color: var(--muted); font-weight: 600; } +.bar { height: 6px; border-radius: 3px; background: var(--bg-elev); overflow: hidden; } +.bar > span { display: block; height: 100%; background: var(--accent); } + +/* Tools */ +.field-row { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; } +.field-row input { + flex: 1 1 120px; min-width: 0; + background: var(--bg-elev); color: var(--text); + border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; font-size: 14px; +} +.btn { + background: var(--accent); color: #fff; border: none; + border-radius: 10px; padding: 10px 18px; font-size: 14px; font-weight: 600; cursor: pointer; +} +.btn:active { transform: scale(.96); } +.output { + background: #0a0f1e; border: 1px solid var(--line); border-radius: 10px; + padding: 12px; font-size: 12px; white-space: pre-wrap; word-break: break-word; + max-height: 240px; overflow: auto; margin: 0; color: #cdd7ee; +} + +/* Footer */ +.app-foot { + position: fixed; bottom: 0; left: 0; right: 0; + padding: 8px 16px calc(env(safe-area-inset-bottom) + 8px); + background: var(--bg-elev); border-top: 1px solid var(--line); + font-size: 11px; color: var(--muted); text-align: center; +} + +.spin { animation: spin 0.8s linear infinite; display: inline-block; } +@keyframes spin { to { transform: rotate(360deg); } } diff --git a/network_extended.py b/network_extended.py new file mode 100644 index 0000000..9018fa4 --- /dev/null +++ b/network_extended.py @@ -0,0 +1,358 @@ +""" +NetworkzeroMonitor - Extended Network Layers Module +Ionity (Pty) Ltd - www.ionity.today + +This module extends the core NetworkMonitor with deeper, full-stack +network details so the mobile app can report everything from the +physical link layer all the way up to GNSS (satellite positioning) +and satellite / cellular wide-area networks. + +Sources are probed opportunistically. When the underlying hardware or +helper tool is not available, each probe returns a structured result +with ``available: False`` and a human-readable ``reason`` instead of +raising, so the API and mobile UI can always render a clean state. + +Layers covered: + - Cellular / mobile broadband (2G/3G/4G/5G) via ModemManager (mmcli) + - Wi-Fi link details (SSID, signal, band) via nmcli / iw + - Satellite internet (Starlink dish) via reachability + gRPC stats + - GNSS / satellite positioning (GPS/GLONASS/Galileo/BeiDou) via gpsd + - A consolidated multi-layer snapshot (link -> IP -> DNS -> WAN -> sat) +""" + +import json +import shutil +import socket +import subprocess +import time +from typing import Dict, List, Optional + + +def _run(cmd: List[str], timeout: int = 6) -> Optional[str]: + """Run a command, returning stdout on success or None on any failure.""" + if not cmd or shutil.which(cmd[0]) is None: + return None + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if out.returncode == 0: + return out.stdout + return None + except (subprocess.TimeoutExpired, OSError): + return None + + +def _now() -> str: + return time.strftime('%Y-%m-%d %H:%M:%S') + + +# Maps ModemManager access technologies to a friendly "generation" label. +_CELL_GENERATION = { + 'gsm': '2G', 'gprs': '2G', 'edge': '2G', + 'umts': '3G', 'hspa': '3G', 'hspa-plus': '3G', 'hspa+': '3G', + 'hsdpa': '3G', 'hsupa': '3G', 'evdo': '3G', '1xrtt': '2G', + 'lte': '4G', 'lte-a': '4G', 'lte-advanced': '4G', + '5gnr': '5G', 'nr5g': '5G', '5g': '5G', +} + + +class ExtendedNetworkMonitor: + """Probes wide-area, wireless and satellite network layers.""" + + # Starlink "Dishy" gRPC management endpoint (default on most installs). + STARLINK_HOST = '192.168.100.1' + STARLINK_PORT = 9200 + + # gpsd default JSON endpoint. + GPSD_HOST = '127.0.0.1' + GPSD_PORT = 2947 + + # ------------------------------------------------------------------ # + # Cellular / mobile broadband (2G / 3G / 4G / 5G) + # ------------------------------------------------------------------ # + def get_cellular_info(self) -> Dict: + """ + Report mobile-broadband modems via ModemManager (``mmcli``). + + Returns a dict with ``available`` plus a list of ``modems``, + each carrying operator, access technology, generation and signal. + """ + result = {'available': False, 'modems': [], 'timestamp': _now()} + + listing = _run(['mmcli', '-L']) + if listing is None: + result['reason'] = 'ModemManager (mmcli) not available or no modem present' + return result + + modem_paths = [tok for tok in listing.split() if '/Modem/' in tok] + if not modem_paths: + result['reason'] = 'No cellular modems detected' + return result + + for path in modem_paths: + detail = _run(['mmcli', '-m', path, '-J']) + modem = self._parse_modem_json(detail) if detail else None + if modem: + result['modems'].append(modem) + + result['available'] = bool(result['modems']) + if not result['available']: + result['reason'] = 'Modem present but details could not be read' + return result + + @staticmethod + def _parse_modem_json(raw: str) -> Optional[Dict]: + try: + data = json.loads(raw).get('modem', {}) + except (json.JSONDecodeError, AttributeError): + return None + + generic = data.get('generic', {}) + access_techs = generic.get('access-technologies', []) or [] + primary_tech = access_techs[0].lower() if access_techs else '' + signal = generic.get('signal-quality', {}) or {} + + return { + 'manufacturer': data.get('modem', {}).get('manufacturer') + if isinstance(data.get('modem'), dict) else None, + 'model': generic.get('model') or generic.get('device-identifier'), + 'operator': generic.get('operator-name') or generic.get('operator-code'), + 'state': generic.get('state'), + 'access_technologies': access_techs, + 'generation': _CELL_GENERATION.get(primary_tech, 'Unknown'), + 'signal_percent': int(signal['value']) if str(signal.get('value', '')).isdigit() else None, + 'signal_recent': signal.get('recent') == 'yes', + } + + # ------------------------------------------------------------------ # + # Wi-Fi link details + # ------------------------------------------------------------------ # + def get_wifi_info(self) -> Dict: + """Report the active Wi-Fi association via ``nmcli`` (or ``iw``).""" + result = {'available': False, 'networks': [], 'timestamp': _now()} + + nmcli_out = _run([ + 'nmcli', '-t', '-f', + 'ACTIVE,SSID,SIGNAL,FREQ,RATE,SECURITY', 'dev', 'wifi' + ]) + if nmcli_out is not None: + for line in nmcli_out.strip().splitlines(): + # nmcli escapes ':' inside fields as '\:' + parts = line.replace('\\:', '\x00').split(':') + parts = [p.replace('\x00', ':') for p in parts] + if len(parts) < 6: + continue + active, ssid, signal, freq, rate, security = parts[:6] + if active != 'yes': + continue + band = '5 GHz' if freq.startswith('5') or freq.startswith('6') else '2.4 GHz' + result['networks'].append({ + 'ssid': ssid or '(hidden)', + 'signal_percent': int(signal) if signal.isdigit() else None, + 'frequency_mhz': int(freq.split()[0]) if freq.split() else None, + 'band': band, + 'rate': rate, + 'security': security or 'open', + }) + result['available'] = bool(result['networks']) + if not result['available']: + result['reason'] = 'No active Wi-Fi association' + return result + + result['reason'] = 'Wi-Fi tooling (nmcli) not available' + return result + + # ------------------------------------------------------------------ # + # Satellite internet (Starlink "Dishy") + # ------------------------------------------------------------------ # + def get_satellite_internet(self) -> Dict: + """ + Report satellite-internet (Starlink) status. + + First checks reachability of the dish management endpoint, then + attempts to pull live telemetry via the gRPC API if the optional + ``grpcurl`` tool is installed. Falls back to a reachability-only + report when telemetry tooling is absent. + """ + result = { + 'available': False, + 'provider': 'Starlink', + 'endpoint': f'{self.STARLINK_HOST}:{self.STARLINK_PORT}', + 'timestamp': _now(), + } + + reachable = self._tcp_reachable(self.STARLINK_HOST, self.STARLINK_PORT, timeout=2) + result['dish_reachable'] = reachable + if not reachable: + result['reason'] = 'Starlink dish endpoint not reachable on this network' + return result + + telemetry = self._starlink_grpc_status() + if telemetry: + result['available'] = True + result.update(telemetry) + else: + # Dish is reachable but we cannot decode telemetry without grpcurl. + result['available'] = True + result['telemetry'] = 'unavailable' + result['reason'] = 'Dish reachable; install grpcurl for live telemetry' + return result + + def _starlink_grpc_status(self) -> Optional[Dict]: + endpoint = f'{self.STARLINK_HOST}:{self.STARLINK_PORT}' + raw = _run([ + 'grpcurl', '-plaintext', '-d', '{"get_status":{}}', + endpoint, 'SpaceX.API.Device.Device/Handle' + ], timeout=8) + if raw is None: + return None + try: + status = json.loads(raw).get('dishGetStatus', {}) + except (json.JSONDecodeError, AttributeError): + return None + + obstruction = status.get('obstructionStats', {}) or {} + return { + 'state': status.get('deviceState', {}).get('uptimeS') and 'online', + 'uptime_s': status.get('deviceState', {}).get('uptimeS'), + 'downlink_bps': status.get('downlinkThroughputBps'), + 'uplink_bps': status.get('uplinkThroughputBps'), + 'ping_latency_ms': status.get('popPingLatencyMs'), + 'ping_drop_rate': status.get('popPingDropRate'), + 'obstruction_fraction': obstruction.get('fractionObstructed'), + 'hardware_version': status.get('deviceInfo', {}).get('hardwareVersion'), + 'software_version': status.get('deviceInfo', {}).get('softwareVersion'), + } + + # ------------------------------------------------------------------ # + # GNSS / satellite positioning + # ------------------------------------------------------------------ # + def get_gnss_info(self) -> Dict: + """ + Report GNSS (GPS/GLONASS/Galileo/BeiDou) positioning via gpsd. + + Connects to the local ``gpsd`` JSON socket and reads a TPV (fix) + and SKY (satellites-in-view) report. Returns ``available: False`` + with a reason when gpsd or a receiver is not present. + """ + result = {'available': False, 'timestamp': _now()} + + try: + sock = socket.create_connection((self.GPSD_HOST, self.GPSD_PORT), timeout=2) + except OSError: + result['reason'] = 'gpsd not running / no GNSS receiver on this device' + return result + + try: + sock.sendall(b'?WATCH={"enable":true,"json":true};\n') + sock.settimeout(4) + tpv, sky = None, None + buffer = '' + deadline = time.time() + 5 + while time.time() < deadline and (tpv is None or sky is None): + try: + chunk = sock.recv(8192).decode('utf-8', errors='ignore') + except socket.timeout: + break + if not chunk: + break + buffer += chunk + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + if msg.get('class') == 'TPV' and tpv is None: + tpv = msg + elif msg.get('class') == 'SKY' and sky is None: + sky = msg + finally: + try: + sock.sendall(b'?WATCH={"enable":false};\n') + except OSError: + pass + sock.close() + + if tpv is None and sky is None: + result['reason'] = 'gpsd reachable but no fix/satellite data yet' + return result + + result['available'] = True + if tpv: + fix_modes = {0: 'unknown', 1: 'no fix', 2: '2D fix', 3: '3D fix'} + result['fix'] = { + 'mode': fix_modes.get(tpv.get('mode', 0), 'unknown'), + 'latitude': tpv.get('lat'), + 'longitude': tpv.get('lon'), + 'altitude_m': tpv.get('alt'), + 'speed_mps': tpv.get('speed'), + 'track_deg': tpv.get('track'), + 'time': tpv.get('time'), + } + if sky: + sats = sky.get('satellites', []) or [] + result['satellites'] = { + 'in_view': len(sats), + 'used': sum(1 for s in sats if s.get('used')), + 'hdop': sky.get('hdop'), + 'vdop': sky.get('vdop'), + 'pdop': sky.get('pdop'), + 'detail': [ + { + 'prn': s.get('PRN'), + 'constellation': self._gnss_constellation(s.get('gnssid')), + 'elevation': s.get('el'), + 'azimuth': s.get('az'), + 'snr': s.get('ss'), + 'used': s.get('used', False), + } + for s in sats + ], + } + return result + + @staticmethod + def _gnss_constellation(gnssid: Optional[int]) -> str: + return { + 0: 'GPS', 1: 'SBAS', 2: 'Galileo', 3: 'BeiDou', + 4: 'IMES', 5: 'QZSS', 6: 'GLONASS', 7: 'NavIC', + }.get(gnssid, 'Unknown') + + # ------------------------------------------------------------------ # + # Helpers + consolidated snapshot + # ------------------------------------------------------------------ # + @staticmethod + def _tcp_reachable(host: str, port: int, timeout: int = 2) -> bool: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + reachable = sock.connect_ex((host, port)) == 0 + sock.close() + return reachable + except OSError: + return False + + def get_all_layers(self) -> Dict: + """ + Return a single consolidated, layered snapshot of every network + tier this module can probe. Designed for the mobile app's + "full stack" view. + """ + return { + 'cellular': self.get_cellular_info(), + 'wifi': self.get_wifi_info(), + 'satellite_internet': self.get_satellite_internet(), + 'gnss': self.get_gnss_info(), + 'timestamp': _now(), + } + + +if __name__ == '__main__': + mon = ExtendedNetworkMonitor() + print("NetworkzeroMonitor - Extended Layers") + print("Ionity (Pty) Ltd - www.ionity.today") + print("=" * 50) + print(json.dumps(mon.get_all_layers(), indent=2)) diff --git a/requirements.txt b/requirements.txt index fe2599a..6ac03d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,6 @@ requests>=2.31.0 # Data visualization matplotlib>=3.8.2 + +# Mobile API server (serves the REST API and installable PWA) +flask>=3.0.0 diff --git a/run_mobile.bat b/run_mobile.bat new file mode 100644 index 0000000..7775855 --- /dev/null +++ b/run_mobile.bat @@ -0,0 +1,14 @@ +@echo off +REM NetworkzeroMonitor - Mobile API server launcher (Windows) +REM Ionity (Pty) Ltd - www.ionity.today +cd /d "%~dp0" + +if exist venv\Scripts\activate.bat call venv\Scripts\activate.bat + +if "%NZM_HOST%"=="" set NZM_HOST=0.0.0.0 +if "%NZM_PORT%"=="" set NZM_PORT=8088 + +echo Open NetworkzeroMonitor on your phone (same Wi-Fi): http://YOUR-PC-IP:%NZM_PORT% +echo. + +python api_server.py diff --git a/run_mobile.sh b/run_mobile.sh new file mode 100755 index 0000000..5a6d9c1 --- /dev/null +++ b/run_mobile.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# NetworkzeroMonitor - Mobile API server launcher (Unix) +# Ionity (Pty) Ltd - www.ionity.today +set -e +cd "$(dirname "$0")" + +if [ -d "venv" ]; then + # shellcheck disable=SC1091 + source venv/bin/activate +fi + +# Host/port can be overridden, e.g. NZM_PORT=9000 ./run_mobile.sh +export NZM_HOST="${NZM_HOST:-0.0.0.0}" +export NZM_PORT="${NZM_PORT:-8088}" + +echo "Open this on your phone (same Wi-Fi):" +echo " http://$(hostname -I 2>/dev/null | awk '{print $1}'):${NZM_PORT}" +echo + +exec python3 api_server.py diff --git a/test_networkzero.py b/test_networkzero.py index fa15b67..5aa4349 100644 --- a/test_networkzero.py +++ b/test_networkzero.py @@ -12,6 +12,7 @@ from unittest.mock import MagicMock, patch from network_monitor import NetworkMonitor, PiHoleMonitor +from network_extended import ExtendedNetworkMonitor, _CELL_GENERATION class TestNetworkMonitorInfo(unittest.TestCase): @@ -220,6 +221,64 @@ def test_check_status_success(self, mock_get): self.assertEqual(result['status'], 'enabled') +class TestExtendedNetworkMonitor(unittest.TestCase): + """Tests for the extended network layers (cellular/wifi/satellite/GNSS).""" + + def setUp(self): + self.ext = ExtendedNetworkMonitor() + + def test_all_layers_structure(self): + layers = self.ext.get_all_layers() + for key in ('cellular', 'wifi', 'satellite_internet', 'gnss', 'timestamp'): + self.assertIn(key, layers) + + def test_each_layer_has_available_flag(self): + layers = self.ext.get_all_layers() + for key in ('cellular', 'wifi', 'satellite_internet', 'gnss'): + self.assertIn('available', layers[key]) + self.assertIsInstance(layers[key]['available'], bool) + + def test_cellular_graceful_when_no_modem(self): + # No mmcli in test env -> graceful unavailable with a reason. + result = self.ext.get_cellular_info() + if not result['available']: + self.assertIn('reason', result) + self.assertEqual(result['modems'], []) + + def test_gnss_constellation_mapping(self): + self.assertEqual(self.ext._gnss_constellation(0), 'GPS') + self.assertEqual(self.ext._gnss_constellation(2), 'Galileo') + self.assertEqual(self.ext._gnss_constellation(6), 'GLONASS') + self.assertEqual(self.ext._gnss_constellation(99), 'Unknown') + + def test_cell_generation_mapping(self): + self.assertEqual(_CELL_GENERATION['lte'], '4G') + self.assertEqual(_CELL_GENERATION['5gnr'], '5G') + self.assertEqual(_CELL_GENERATION['umts'], '3G') + self.assertEqual(_CELL_GENERATION['gsm'], '2G') + + def test_parse_modem_json(self): + raw = ( + '{"modem": {"generic": {"operator-name": "TestNet", ' + '"access-technologies": ["lte"], "state": "connected", ' + '"signal-quality": {"value": "72", "recent": "yes"}, ' + '"model": "TestModem"}}}' + ) + modem = ExtendedNetworkMonitor._parse_modem_json(raw) + self.assertEqual(modem['operator'], 'TestNet') + self.assertEqual(modem['generation'], '4G') + self.assertEqual(modem['signal_percent'], 72) + self.assertTrue(modem['signal_recent']) + + def test_parse_modem_json_invalid(self): + self.assertIsNone(ExtendedNetworkMonitor._parse_modem_json('not json')) + + def test_satellite_reports_provider(self): + result = self.ext.get_satellite_internet() + self.assertEqual(result['provider'], 'Starlink') + self.assertIn('dish_reachable', result) + + def run_manual_demo(): """Quick manual demo that prints results to stdout.""" print("=" * 60)