gemini-computer-use 0.2.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +733 -16
  2. package/bin/cli.js +112 -17
  3. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  4. package/core/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/core/__pycache__/code_cache.cpython-313.pyc +0 -0
  6. package/core/__pycache__/mcp_tools.cpython-313.pyc +0 -0
  7. package/core/__pycache__/schemas.cpython-313.pyc +0 -0
  8. package/core/__pycache__/server.cpython-313.pyc +0 -0
  9. package/core/__pycache__/vitals.cpython-313.pyc +0 -0
  10. package/core/__pycache__/web_share.cpython-313.pyc +0 -0
  11. package/core/agent.py +503 -18
  12. package/core/agent_harness.py +9 -2
  13. package/core/code_cache.py +15 -3
  14. package/core/device.py +2400 -0
  15. package/core/domain.py +303 -0
  16. package/core/mcp_tools.py +1840 -112
  17. package/core/policies.py +35 -5
  18. package/core/server.py +78 -32
  19. package/core/termux.py +916 -0
  20. package/core/updater.py +1713 -0
  21. package/core/version.py +31 -0
  22. package/core/vitals.py +66 -2
  23. package/core/web_share.py +814 -0
  24. package/gateway.py +1469 -98
  25. package/install-gui.cmd +40 -0
  26. package/install-gui.ps1 +1882 -0
  27. package/install-gui.strings.json +211 -0
  28. package/install.ps1 +960 -92
  29. package/install.sh +1214 -81
  30. package/ops/doctor.ps1 +735 -0
  31. package/ops/mesh-domain.sh +86 -0
  32. package/ops/nginx/README.md +77 -0
  33. package/ops/nginx/antigravity-mesh-mcp.conf +172 -0
  34. package/ops/nginx/mesh-domain.vhost.template +58 -0
  35. package/ops/nginx/render-domain.sh +123 -0
  36. package/ops/update.ps1 +373 -0
  37. package/ops/windows/README.md +298 -0
  38. package/ops/windows/agent-watchdog.ps1 +575 -0
  39. package/ops/windows/run-hidden.vbs +64 -0
  40. package/package.json +6 -2
  41. package/skills/orchestrator.md +12 -0
package/core/device.py ADDED
@@ -0,0 +1,2400 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """core/device.py - telemetry of the device this node runs on, on every platform.
4
+
5
+ One collector API, several sources:
6
+
7
+ * **Android / Termux** - the ``termux-api`` commands (through :mod:`core.termux`
8
+ when it is present), ``/proc``, ``/sys`` and ``getprop``;
9
+ * **Linux** - ``/sys/class/power_supply``, ``/proc``, ``/sys``, ``iw``/``nmcli``;
10
+ * **Windows** - ``ctypes`` and ``winreg`` where that is enough (battery, memory,
11
+ locale, CPU name), PowerShell CIM only where it is not (network, cameras,
12
+ microphones);
13
+ * **macOS** - ``pmset``, ``sysctl``, ``airport``, ``system_profiler``.
14
+
15
+ One resolver, four scenarios, one table:
16
+
17
+ * :func:`scenario` is the *only* place that decides which of the four code paths
18
+ this node takes - ``os.name`` first (a phone is never Windows), then the
19
+ Termux signals, then Darwin, then Linux as the fallback;
20
+ * ``_SECTION_IMPL`` maps every section to one implementation per scenario, so
21
+ "what runs on a phone" is a lookup instead of a trail of ``if``s, and a
22
+ scenario with no source for a section says *why* instead of borrowing another
23
+ platform's answer;
24
+ * :func:`scenario_report`, :func:`matrix_problems` and :func:`scenario_commands`
25
+ turn that matrix into something a tool, a bug report and CI can check: the
26
+ tools a scenario is allowed to run are data attached to the implementations
27
+ themselves, never a second hand-written list.
28
+
29
+ Contract (the whole point of this module):
30
+
31
+ * **never raises** - every collector returns a block, and a block that has no
32
+ answer says ``{"available": False, "reason": ...}`` instead of a fabricated
33
+ zero. A model must never read "0 %" where the truth is "this platform does not
34
+ expose it";
35
+ * every block carries ``source`` - where the number came from;
36
+ * collectors run under **one deadline** in a thread pool, so one slow source
37
+ (``system_profiler``, a frozen Termux:API app) cannot stall ``system_info``;
38
+ * results are cached for a few seconds; ``fresh=True`` bypasses the cache. The
39
+ cache key is ``(section, scenario)``: a block collected for another scenario
40
+ would be a lie about this machine;
41
+ * every section x scenario pair exists (``matrix_problems() == []``), and a
42
+ scenario runs only the external tools its implementations declare
43
+ (``scenario_commands()``).
44
+
45
+ Sections and their fields::
46
+
47
+ device class, model, manufacturer, os, release, arch, android_*,
48
+ termux_api, termux_version
49
+ battery percent, status, plugged, health, temperature_c, current_ua,
50
+ time_remaining_s, ac_online
51
+ network interfaces[{name, kind, up, macs, ipv4, ipv6}], default_route,
52
+ wifi{ssid, bssid, rssi_dbm, link_speed_mbps, frequency_mhz} | None,
53
+ cellular{operator, network_type, signal_dbm, level, signal_pct,
54
+ roaming, data_state, sim_state} | None,
55
+ signal (one-line summary for a model)
56
+ locale language, languages[], region, encoding
57
+ time iso_local, utc, epoch, utc_offset, utc_offset_minutes, timezone
58
+ hardware cpu{model, cores, logical, freq_mhz_cur[], freq_mhz_max, load,
59
+ temp_c}, memory{total_mb, used_mb, free_mb, used_pct, swap_*},
60
+ thermal[{name, value, unit}]
61
+ storage root, total_gb, free_gb, used_pct, shared, capture_dir
62
+ cameras [{id, facing, max_resolution, ...}], count
63
+ microphones [{name, kind}], count
64
+ sensors [{name, value?, unit?}], sampling ("names" | "values")
65
+ capabilities per-API availability on a phone, plus which sections answer
66
+
67
+ Consumers: :mod:`core.mcp_tools` (``system_info``, ``mesh_status``,
68
+ ``system_vitals`` and the ``device_*`` tools). Nothing here imports
69
+ ``mcp_tools`` - the dependency points one way only.
70
+ """
71
+
72
+ from __future__ import annotations
73
+
74
+ import glob as _glob
75
+ import json
76
+ import locale as _locale
77
+ import os
78
+ import platform
79
+ import re
80
+ import shutil
81
+ import subprocess
82
+ import sys
83
+ import threading
84
+ import time
85
+ from concurrent.futures import ThreadPoolExecutor
86
+ from datetime import datetime, timezone
87
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
88
+
89
+ #: Host mechanics, *not* the scenario decision: these say what this interpreter
90
+ #: runs on (which code page a child writes, whether a console window would flash),
91
+ #: while :func:`scenario` says which collector path to take.
92
+ _IS_WINDOWS = os.name == "nt"
93
+ _SYSTEM = platform.system()
94
+ _IS_DARWIN = _SYSTEM == "Darwin"
95
+ #: Windows: keep console windows from flashing for every probe.
96
+ _POPEN_FLAGS = 0x08000000 if _IS_WINDOWS else 0
97
+
98
+ #: Every section ``collect()`` knows about, in the order a report reads best.
99
+ SECTIONS: Tuple[str, ...] = (
100
+ "device", "battery", "network", "locale", "time", "hardware",
101
+ "storage", "cameras", "microphones", "sensors",
102
+ )
103
+
104
+ #: Sections that answer from local files in milliseconds on every platform.
105
+ #: ``system_info`` uses this set: it is the "cheap and always interesting" half,
106
+ #: while ``device_info`` can ask for everything.
107
+ CHEAP_SECTIONS: Tuple[str, ...] = (
108
+ "device", "battery", "locale", "time", "hardware", "storage",
109
+ )
110
+
111
+ #: Sections that need an external tool, a permission or a slow profiler. They are
112
+ #: still part of ``system_info`` by default, but an operator who wants a status
113
+ #: call to stay instant sets ``MESH_DEVICE_QUICK=1`` and only these are skipped.
114
+ SLOW_SECTIONS: Tuple[str, ...] = ("network", "cameras", "microphones", "sensors")
115
+
116
+ #: The full report ``device_info(section="all")`` returns.
117
+ ALL_SECTIONS: Tuple[str, ...] = SECTIONS + ("capabilities",)
118
+
119
+ #: The four code paths this node can take. Exactly one is in effect per process,
120
+ #: and :func:`scenario` is the only function allowed to decide which one: a
121
+ #: second decision point is how a Linux box ends up answering like a phone.
122
+ SCENARIOS: Tuple[str, ...] = ("termux", "linux", "windows", "darwin")
123
+
124
+ #: Per-section cache lifetime in seconds. Battery and load move; a camera list
125
+ #: does not.
126
+ _CACHE_TTL: Dict[str, float] = {
127
+ "device": 60.0,
128
+ "battery": 5.0,
129
+ "network": 5.0,
130
+ "locale": 30.0,
131
+ "time": 1.0,
132
+ "hardware": 3.0,
133
+ "storage": 10.0,
134
+ "cameras": 60.0,
135
+ "microphones": 60.0,
136
+ "sensors": 30.0,
137
+ "capabilities": 60.0,
138
+ }
139
+
140
+ #: Default wall-clock budget for one ``collect()`` call, in seconds. Both the
141
+ #: pool and each future respect it.
142
+ DEFAULT_DEADLINE = 8.0
143
+ #: Default budget for one external command.
144
+ DEFAULT_COMMAND_TIMEOUT = 5.0
145
+
146
+ #: Told to the model whenever a ``termux-*`` command is missing: the Android app
147
+ #: is a separate install from the package, and only both together answer.
148
+ TERMUX_API_FIX = ("install the Termux:API app (F-Droid or GitHub, the *same* store "
149
+ "as Termux) and run `pkg install termux-api` in Termux")
150
+
151
+ #: Termux's fixed application data directory: the third detection signal, and the
152
+ #: only one that still identifies a phone with a scrubbed environment (a ``su``
153
+ #: session, a service started by ``Termux:Boot``).
154
+ _TERMUX_DATA_DIR = "/data/data/com.termux/files/usr/bin"
155
+
156
+ _CACHE: Dict[Tuple[str, str], Tuple[float, Dict[str, Any]]] = {}
157
+ _CACHE_LOCK = threading.RLock()
158
+
159
+ _CAPTURE_DIR: Optional[str] = None
160
+ _MAX_LIST = 12
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Small helpers
165
+ # ---------------------------------------------------------------------------
166
+
167
+ _DECODINGS: Optional[List[str]] = None
168
+
169
+
170
+ def _windows_code_pages() -> List[str]:
171
+ """Code pages a Windows child may write with: OEM console CP first, then ANSI."""
172
+ pages: List[str] = []
173
+ try:
174
+ import ctypes
175
+
176
+ for getter in ("GetOEMCP", "GetACP"):
177
+ try:
178
+ code_page = int(getattr(ctypes.windll.kernel32, getter)())
179
+ except Exception:
180
+ continue
181
+ if code_page > 0:
182
+ pages.append("cp%d" % code_page)
183
+ except Exception:
184
+ pass
185
+ return pages
186
+
187
+
188
+ def _decodings() -> List[str]:
189
+ """Encodings to try for captured child output, most likely first.
190
+
191
+ Order matters, because every single-byte code page "succeeds" at decoding
192
+ anything: UTF-8 is what Termux, git and PowerShell 7 write, the **OEM** code
193
+ page is what cmd.exe, Windows PowerShell and the native console tools behind
194
+ it write (cp866 on a Russian Windows - trying ANSI first turned every
195
+ localized device name into mojibake), and ANSI is the last resort.
196
+ """
197
+ global _DECODINGS
198
+ if _DECODINGS is not None:
199
+ return _DECODINGS
200
+ candidates = ["utf-8"]
201
+ if _IS_WINDOWS:
202
+ candidates.extend(_windows_code_pages())
203
+ else:
204
+ try:
205
+ candidates.append(_locale.getpreferredencoding(False))
206
+ except Exception:
207
+ pass
208
+ candidates.append("cp1252")
209
+ unique: List[str] = []
210
+ seen = set()
211
+ for name in candidates:
212
+ if not name:
213
+ continue
214
+ key = name.lower()
215
+ if key in seen:
216
+ continue
217
+ seen.add(key)
218
+ unique.append(name)
219
+ _DECODINGS = unique
220
+ return unique
221
+
222
+
223
+ def _decode(data: Any) -> str:
224
+ """Decode captured bytes into text without mojibake (see :func:`_decodings`)."""
225
+ if data is None:
226
+ return ""
227
+ if isinstance(data, str):
228
+ return data.strip()
229
+ if isinstance(data, (bytes, bytearray)):
230
+ for encoding in _decodings():
231
+ try:
232
+ # Line endings are normalised, so a Windows child's \r\n cannot
233
+ # leave a stray \r that breaks a `$`-anchored regex further down.
234
+ return (bytes(data).decode(encoding).replace("\r\n", "\n")
235
+ .replace("\r", "\n").strip())
236
+ except (UnicodeDecodeError, LookupError):
237
+ continue
238
+ return bytes(data).decode("utf-8", "replace").replace("\r\n", "\n").strip()
239
+ return str(data).strip()
240
+
241
+
242
+ def _run(argv: Sequence[str], timeout: float = DEFAULT_COMMAND_TIMEOUT) -> Tuple[int, str]:
243
+ """Run one command with no shell, no stdin and a hard timeout.
244
+
245
+ Returns ``(returncode, output)``; ``(-1, "")`` means "could not run at all".
246
+ Nothing here raises: a missing tool is a normal answer on a phone.
247
+ """
248
+ try:
249
+ proc = subprocess.run(
250
+ list(argv),
251
+ stdin=subprocess.DEVNULL,
252
+ stdout=subprocess.PIPE,
253
+ stderr=subprocess.PIPE,
254
+ timeout=max(0.2, float(timeout)),
255
+ creationflags=_POPEN_FLAGS,
256
+ )
257
+ except Exception:
258
+ return -1, ""
259
+ text = _decode(proc.stdout) or _decode(proc.stderr)
260
+ return int(proc.returncode), text
261
+
262
+
263
+ def _read_text(path: str) -> str:
264
+ try:
265
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
266
+ return handle.read().strip()
267
+ except Exception:
268
+ return ""
269
+
270
+
271
+ def _read_int(path: str) -> Optional[int]:
272
+ text = _read_text(path)
273
+ if not text:
274
+ return None
275
+ try:
276
+ return int(text.split()[0])
277
+ except Exception:
278
+ return None
279
+
280
+
281
+ def _which(name: str) -> Optional[str]:
282
+ try:
283
+ return shutil.which(name)
284
+ except Exception:
285
+ return None
286
+
287
+
288
+ def _block(source: str, **fields: Any) -> Dict[str, Any]:
289
+ block: Dict[str, Any] = {"available": True, "source": source}
290
+ block.update(fields)
291
+ return block
292
+
293
+
294
+ def _unavailable(reason: str, source: str = "", fix: str = "", **fields: Any) -> Dict[str, Any]:
295
+ block: Dict[str, Any] = {"available": False, "reason": reason}
296
+ if source:
297
+ block["source"] = source
298
+ if fix:
299
+ block["fix"] = fix
300
+ block.update(fields)
301
+ return block
302
+
303
+
304
+ def _runs(*commands: str):
305
+ """Declare the external commands one implementation is allowed to execute.
306
+
307
+ The list lives on the function object instead of in a second table, so
308
+ :func:`scenario_commands` cannot drift from the code that actually runs: an
309
+ implementation that starts calling a new tool without declaring it fails the
310
+ scenario tests, and a scenario can never quietly inherit another platform's
311
+ tools.
312
+ """
313
+ def decorate(func):
314
+ existing = tuple(getattr(func, "commands", ()))
315
+ func.commands = tuple(dict.fromkeys(existing + commands)) # type: ignore[attr-defined]
316
+ return func
317
+ return decorate
318
+
319
+
320
+ def _safe_call(producer, source: str = "") -> Dict[str, Any]:
321
+ """Run one collector so that a broken one is still an answer.
322
+
323
+ The contract is that nothing raises: a collector that throws, or that returns
324
+ something which is not a block, becomes ``available: False`` with the cause
325
+ named in ``reason``.
326
+ """
327
+ name = source or getattr(producer, "__name__", "collector")
328
+ try:
329
+ value = producer()
330
+ except Exception as exc: # a collector must never escape
331
+ return _unavailable("%s: %s" % (type(exc).__name__, exc), source=name)
332
+ if not isinstance(value, dict):
333
+ return _unavailable("collector %s returned %r" % (name, type(value).__name__),
334
+ source=name)
335
+ return value
336
+
337
+
338
+ def _cached(name: str, producer, fresh: bool = False) -> Dict[str, Any]:
339
+ """Memoize one collector for ``_CACHE_TTL[name]`` seconds, per scenario.
340
+
341
+ The key is ``(section, scenario)``, not the section alone: the environment
342
+ can change inside one process (a test, a wrapper that exports
343
+ ``TERMUX_VERSION``, a node that re-reads its configuration), and a block
344
+ collected for another scenario would be a lie about *this* machine.
345
+ """
346
+ ttl = _CACHE_TTL.get(name, 5.0)
347
+ key = (name, scenario())
348
+ now = time.monotonic()
349
+ if not fresh:
350
+ with _CACHE_LOCK:
351
+ hit = _CACHE.get(key)
352
+ if hit and hit[0] > now:
353
+ return hit[1]
354
+ value = _safe_call(producer, source=name)
355
+ with _CACHE_LOCK:
356
+ _CACHE[key] = (now + ttl, value)
357
+ return value
358
+
359
+
360
+ def clear_cache() -> None:
361
+ """Drop every cached block (used by tests and by ``fresh=True`` paths)."""
362
+ with _CACHE_LOCK:
363
+ _CACHE.clear()
364
+
365
+
366
+ def configure(capture_dir: Optional[str] = None) -> None:
367
+ """Set module state that comes from ``core.mcp_tools.configure()``."""
368
+ global _CAPTURE_DIR
369
+ _CAPTURE_DIR = str(capture_dir) if capture_dir else None
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # Platform detection: the one place that decides which code path runs
374
+ # ---------------------------------------------------------------------------
375
+
376
+ def _termux_data_dir() -> bool:
377
+ """True when Termux's fixed application data directory exists.
378
+
379
+ The only Termux signal that survives a scrubbed environment, and the one a
380
+ phone always has. It is a separate function so a test can decide the answer
381
+ without depending on the host's filesystem or on running on Android.
382
+ """
383
+ try:
384
+ return os.path.isdir(_TERMUX_DATA_DIR)
385
+ except Exception:
386
+ return False
387
+
388
+
389
+ def _platform_signals() -> Dict[str, Any]:
390
+ """The raw evidence :func:`scenario` reads - no interpretation, no cache."""
391
+ return {
392
+ "TERMUX_VERSION": os.environ.get("TERMUX_VERSION", ""),
393
+ "PREFIX": os.environ.get("PREFIX", ""),
394
+ "termux_data_dir": _termux_data_dir(),
395
+ "platform_system": platform.system(),
396
+ }
397
+
398
+
399
+ def scenario() -> str:
400
+ """The single place that decides which code path this node must take.
401
+
402
+ The order is the contract, not a preference:
403
+
404
+ 1. ``os.name == "nt"`` wins outright. A phone is never Windows, and a
405
+ Windows host that happens to export ``TERMUX_VERSION`` (a CI matrix, a
406
+ wrapper script, a WSL environment leaking through) must still take the
407
+ Windows path;
408
+ 2. Termux is checked next, because a phone reports ``Linux`` from
409
+ ``platform.system()`` and would otherwise be mistaken for a desktop;
410
+ 3. Darwin, because macOS is POSIX too but owns its tools;
411
+ 4. Linux is the fallback, which is what a server, a container and an
412
+ unrecognised POSIX host all are.
413
+
414
+ Nothing is cached: the answer costs a few environment reads and one
415
+ ``os.path.isdir``, and a cached answer would make a node that was restarted
416
+ with a changed environment - and every scenario test - lie.
417
+ """
418
+ if os.name == "nt":
419
+ return "windows"
420
+ signals = _platform_signals()
421
+ if (signals["TERMUX_VERSION"] or "com.termux" in signals["PREFIX"]
422
+ or signals["termux_data_dir"]):
423
+ return "termux"
424
+ if signals["platform_system"] == "Darwin":
425
+ return "darwin"
426
+ return "linux"
427
+
428
+
429
+ def scenario_report() -> Dict[str, Any]:
430
+ """Why the resolver answered what it answered, for tools and bug reports.
431
+
432
+ ``signals`` is deliberately raw (the environment values as they are, the
433
+ filesystem fact, the platform string) so a user can see which signal decided
434
+ it, and ``checked`` names the branches in the order :func:`scenario` tried
435
+ them.
436
+ """
437
+ return {
438
+ "scenario": scenario(),
439
+ "platform": platform.system(),
440
+ "os_name": os.name,
441
+ "signals": _platform_signals(),
442
+ "checked": ["windows", "termux", "darwin", "linux"],
443
+ }
444
+
445
+
446
+ def is_termux() -> bool:
447
+ """True on an Android phone running Termux.
448
+
449
+ A thin read of :func:`scenario`, kept because callers outside this module ask
450
+ the yes/no question. It is deliberately *not* a second detection path: the
451
+ signals are the same ones ``core.domain.is_termux()`` uses (``TERMUX_VERSION``,
452
+ ``$PREFIX`` inside ``com.termux``, the application data directory), and
453
+ ``tests/test_device_scenarios.py`` pins the two answers together.
454
+ """
455
+ return scenario() == "termux"
456
+
457
+
458
+ def _android_getprop(name: str) -> str:
459
+ getprop = _which("getprop") or "/system/bin/getprop"
460
+ rc, text = _run([getprop, name], timeout=2.0)
461
+ return text if rc == 0 else ""
462
+
463
+
464
+ def _termux_adapter():
465
+ """Return :mod:`core.termux` when this checkout has it, else ``None``."""
466
+ try:
467
+ from core import termux # type: ignore
468
+ return termux
469
+ except Exception:
470
+ return None
471
+
472
+
473
+ def _termux_json(command: str, args: Sequence[str] = (), timeout: float = DEFAULT_COMMAND_TIMEOUT,
474
+ adapter=None) -> Dict[str, Any]:
475
+ """Run one ``termux-*`` command and return ``{"ok", "value"|"reason", ...}``.
476
+
477
+ ``core.termux`` is the full adapter (capabilities, wake lock, permission
478
+ mapping) and is preferred; the local path exists so this module still reports
479
+ a phone correctly in a checkout that has not got that file.
480
+ """
481
+ adapter = adapter if adapter is not None else _termux_adapter()
482
+ if adapter is not None:
483
+ try:
484
+ return adapter.run_json(command, args, timeout)
485
+ except Exception as exc:
486
+ return {"ok": False, "reason": "%s: %s" % (type(exc).__name__, exc)}
487
+ exe = _which(command)
488
+ if not exe:
489
+ return {"ok": False, "reason": "the %s command is not installed" % command,
490
+ "fix": TERMUX_API_FIX, "missing": True}
491
+ rc, text = _run([exe] + [str(a) for a in args], timeout=timeout)
492
+ if rc != 0:
493
+ return {"ok": False, "reason": text or ("%s exited with %d" % (command, rc))}
494
+ if not text:
495
+ return {"ok": False, "reason": "%s returned nothing" % command}
496
+ try:
497
+ return {"ok": True, "value": json.loads(text), "raw": text}
498
+ except Exception:
499
+ return {"ok": True, "value": text, "raw": text}
500
+
501
+
502
+ def _cmd_report(result: Dict[str, Any]) -> Dict[str, Any]:
503
+ """Turn a termux result into the fields a block needs (``fix`` included)."""
504
+ if result.get("ok"):
505
+ return {"value": result.get("value")}
506
+ fields = {"reason": result.get("reason") or "the command did not answer"}
507
+ if result.get("fix"):
508
+ fields["fix"] = result["fix"]
509
+ return fields
510
+
511
+
512
+ # ---------------------------------------------------------------------------
513
+ # device: what kind of machine is this
514
+ # ---------------------------------------------------------------------------
515
+
516
+ def _windows_registry(path: str, name: str) -> str:
517
+ try:
518
+ import winreg
519
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as key:
520
+ value, _kind = winreg.QueryValueEx(key, name)
521
+ return str(value or "").strip()
522
+ except Exception:
523
+ return ""
524
+
525
+
526
+ def _linux_dmi(name: str) -> str:
527
+ return _read_text("/sys/devices/virtual/dmi/id/%s" % name)
528
+
529
+
530
+ def _classify(has_battery: bool) -> str:
531
+ """phone | tablet | laptop | desktop | server - from cheap local evidence.
532
+
533
+ Android answers this itself. Elsewhere a battery means a portable machine; a
534
+ machine with no battery and no desktop session is a server, which is what a
535
+ VPS is and what the model should assume before suggesting GUI work. The
536
+ branch is on :func:`scenario` so a Windows box is never asked for a Linux
537
+ chassis file, and a Mac without a battery is never called a server (macOS has
538
+ no XDG desktop session to read).
539
+ """
540
+ case = scenario()
541
+ if case == "termux":
542
+ characteristics = (_android_getprop("ro.build.characteristics") or "").lower()
543
+ return "tablet" if "tablet" in characteristics else "phone"
544
+ if case in ("windows", "darwin"):
545
+ return "laptop" if has_battery else "desktop"
546
+ if has_battery:
547
+ chassis = _read_int("/sys/class/dmi/id/chassis_type")
548
+ if chassis in (30, 31):
549
+ return "tablet"
550
+ return "laptop"
551
+ desktop_session = (os.environ.get("XDG_CURRENT_DESKTOP")
552
+ or os.environ.get("DESKTOP_SESSION")
553
+ or os.environ.get("DISPLAY")
554
+ or os.environ.get("WAYLAND_DISPLAY"))
555
+ return "desktop" if desktop_session else "server"
556
+
557
+
558
+ def _device_base_fields() -> Dict[str, Any]:
559
+ """The fields every platform answers without a subprocess."""
560
+ return {
561
+ "os": platform.platform(),
562
+ "release": platform.release(),
563
+ "arch": platform.machine(),
564
+ "python": platform.python_version(),
565
+ }
566
+
567
+
568
+ @_runs("getprop")
569
+ def _device_termux() -> Dict[str, Any]:
570
+ """A phone: the hardware names are Android system properties."""
571
+ fields = _device_base_fields()
572
+ fields.update({
573
+ "model": _android_getprop("ro.product.model") or _android_getprop("ro.product.device"),
574
+ "manufacturer": _android_getprop("ro.product.manufacturer"),
575
+ "android_release": _android_getprop("ro.build.version.release"),
576
+ "android_sdk": _android_getprop("ro.build.version.sdk"),
577
+ "characteristics": _android_getprop("ro.build.characteristics"),
578
+ "abi": _android_getprop("ro.product.cpu.abi") or platform.machine(),
579
+ "termux_version": os.environ.get("TERMUX_VERSION", ""),
580
+ "prefix": os.environ.get("PREFIX", ""),
581
+ })
582
+ adapter = _termux_adapter()
583
+ if adapter is not None:
584
+ try:
585
+ fields["termux_api"] = adapter.api_installed()
586
+ except Exception:
587
+ fields["termux_api"] = {"installed": bool(_which("termux-battery-status"))}
588
+ else:
589
+ fields["termux_api"] = {"installed": bool(_which("termux-battery-status")),
590
+ "commands": {}}
591
+ fields["class"] = _classify(True) # a phone always has a battery
592
+ return _block("getprop + termux-api", **fields)
593
+
594
+
595
+ def _device_windows() -> Dict[str, Any]:
596
+ """A Windows box: the model lives in the registry, not in a file."""
597
+ fields = _device_base_fields()
598
+ fields.update({
599
+ "model": _windows_registry(
600
+ r"HARDWARE\DESCRIPTION\System\BIOS", "SystemProductName"),
601
+ "manufacturer": _windows_registry(
602
+ r"HARDWARE\DESCRIPTION\System\BIOS", "SystemManufacturer"),
603
+ })
604
+ fields["class"] = _classify(_battery_present())
605
+ return _block("winreg + platform", **fields)
606
+
607
+
608
+ @_runs("sysctl", "pmset")
609
+ def _device_darwin() -> Dict[str, Any]:
610
+ """A Mac: one sysctl names the hardware model, pmset says portable or not."""
611
+ fields = _device_base_fields()
612
+ _rc, model = _run(["sysctl", "-n", "hw.model"], timeout=2.0)
613
+ fields.update({"model": model, "manufacturer": "Apple"})
614
+ fields["class"] = _classify(_battery_present())
615
+ return _block("sysctl + platform", **fields)
616
+
617
+
618
+ def _device_linux() -> Dict[str, Any]:
619
+ """A PC or a server: the DMI tables under /sys name the hardware."""
620
+ fields = _device_base_fields()
621
+ fields.update({
622
+ "model": _linux_dmi("product_name") or _linux_dmi("product_version"),
623
+ "manufacturer": _linux_dmi("sys_vendor"),
624
+ })
625
+ fields["class"] = _classify(_battery_present())
626
+ return _block("/sys/devices/virtual/dmi/id + platform", **fields)
627
+
628
+
629
+ def _battery_present() -> bool:
630
+ """Cheaply answered question: does this machine have a battery at all?"""
631
+ case = scenario()
632
+ if case == "termux":
633
+ return True
634
+ if case == "windows":
635
+ return _windows_power_status().get("BatteryFlag") not in (128, 255, None)
636
+ if case == "darwin":
637
+ _rc, text = _run(["pmset", "-g", "batt"], timeout=3.0)
638
+ return "InternalBattery" in text
639
+ return bool(_glob.glob("/sys/class/power_supply/BAT*"))
640
+
641
+
642
+ # ---------------------------------------------------------------------------
643
+ # battery
644
+ # ---------------------------------------------------------------------------
645
+
646
+ def _windows_power_status() -> Dict[str, Any]:
647
+ """``GetSystemPowerStatus`` de-duplicated: one call for battery and AC.
648
+
649
+ No PowerShell, no CIM, no console window - which matters because
650
+ ``system_info`` runs on every node and this is the one value a phone user
651
+ looks at first.
652
+ """
653
+ if scenario() != "windows":
654
+ return {}
655
+ try:
656
+ import ctypes
657
+ from ctypes import wintypes
658
+
659
+ class SYSTEM_POWER_STATUS(ctypes.Structure):
660
+ _fields_ = [
661
+ ("ACLineStatus", wintypes.BYTE),
662
+ ("BatteryFlag", wintypes.BYTE),
663
+ ("BatteryLifePercent", wintypes.BYTE),
664
+ ("SystemStatusFlag", wintypes.BYTE),
665
+ ("BatteryLifeTime", wintypes.DWORD),
666
+ ("BatteryFullLifeTime", wintypes.DWORD),
667
+ ]
668
+
669
+ status = SYSTEM_POWER_STATUS()
670
+ if not ctypes.windll.kernel32.GetSystemPowerStatus(ctypes.byref(status)):
671
+ return {}
672
+ percent: Optional[int] = int(status.BatteryLifePercent)
673
+ if percent == 255:
674
+ percent = None
675
+ ac: Optional[bool]
676
+ if status.ACLineStatus == 1:
677
+ ac = True
678
+ elif status.ACLineStatus == 0:
679
+ ac = False
680
+ else:
681
+ ac = None
682
+ flags = int(status.BatteryFlag)
683
+ return {
684
+ "BatteryFlag": flags,
685
+ "percent": percent,
686
+ "ac_online": ac,
687
+ "charging": bool(flags & 8) if flags not in (128, 255) else None,
688
+ "no_battery": flags in (128, 255),
689
+ "time_remaining_s": (int(status.BatteryLifeTime)
690
+ if status.BatteryLifeTime not in (0xFFFFFFFF, 0) else None),
691
+ }
692
+ except Exception:
693
+ return {}
694
+
695
+
696
+ def _linux_battery() -> Optional[Dict[str, Any]]:
697
+ base = "/sys/class/power_supply"
698
+ if not os.path.isdir(base):
699
+ return None
700
+ for entry in sorted(os.listdir(base)):
701
+ if not entry.upper().startswith("BAT"):
702
+ continue
703
+ path = os.path.join(base, entry)
704
+ percent = _read_int(os.path.join(path, "capacity"))
705
+ if percent is None:
706
+ # Some kernels expose only charge_now/charge_full.
707
+ now = _read_int(os.path.join(path, "charge_now"))
708
+ full = _read_int(os.path.join(path, "charge_full"))
709
+ if now is not None and full:
710
+ percent = int(round(100.0 * now / full))
711
+ temp = _read_int(os.path.join(path, "temp"))
712
+ current = _read_int(os.path.join(path, "current_now"))
713
+ power = _read_int(os.path.join(path, "power_now"))
714
+ ac_online = None
715
+ for ac in sorted(os.listdir(base)):
716
+ upper = ac.upper()
717
+ if upper.startswith("AC") or upper.startswith("ADP") or upper == "ACAD":
718
+ online = _read_int(os.path.join(base, ac, "online"))
719
+ if online is not None:
720
+ ac_online = bool(online)
721
+ break
722
+ return {
723
+ "percent": percent,
724
+ "status": _read_text(os.path.join(path, "status")).lower(),
725
+ "health": _read_text(os.path.join(path, "health")).lower(),
726
+ "temperature_c": round(temp / 10.0, 1) if temp is not None else None,
727
+ "current_ua": current if current is not None else None,
728
+ "power_uw": power if power is not None else None,
729
+ "ac_online": ac_online,
730
+ "name": entry,
731
+ }
732
+ return None
733
+
734
+
735
+ def _darwin_battery() -> Optional[Dict[str, Any]]:
736
+ rc, text = _run(["pmset", "-g", "batt"], timeout=4.0)
737
+ if rc != 0 or "InternalBattery" not in text:
738
+ return None
739
+ percent = None
740
+ match = re.search(r"(\d{1,3})%", text)
741
+ if match:
742
+ percent = int(match.group(1))
743
+ # The status is the word right after the percentage. Scanning for a bare word
744
+ # is not enough: "discharging" contains "charging", so the shorter word won and
745
+ # a Mac running on its battery was reported as "charging".
746
+ status = ""
747
+ match = re.search(r"\d{1,3}%;\s*([^;]+)", text)
748
+ if match:
749
+ status = match.group(1).strip().lower()
750
+ if not status:
751
+ for word in ("finishing charge", "discharging", "charging", "charged"):
752
+ if word in text:
753
+ status = word
754
+ break
755
+ remaining = None
756
+ match = re.search(r"(\d+):(\d{2})\s+remaining", text)
757
+ if match:
758
+ remaining = int(match.group(1)) * 3600 + int(match.group(2)) * 60
759
+ return {
760
+ "percent": percent,
761
+ "status": status,
762
+ "health": "",
763
+ "temperature_c": None,
764
+ "ac_online": ("AC Power" in text) or None,
765
+ "time_remaining_s": remaining,
766
+ }
767
+
768
+
769
+ @_runs("termux-battery-status")
770
+ def _battery_termux() -> Dict[str, Any]:
771
+ """A phone: the Termux:API app is the only battery source that exists."""
772
+ result = _termux_json("termux-battery-status", timeout=4.0)
773
+ if not result.get("ok"):
774
+ return _unavailable(**{
775
+ "reason": result.get("reason") or "termux-battery-status did not answer",
776
+ **({"fix": result["fix"]} if result.get("fix") else {}),
777
+ })
778
+ value = result.get("value")
779
+ if not isinstance(value, dict):
780
+ return _unavailable("termux-battery-status answered %r" % (value,),
781
+ source="termux-battery-status")
782
+ percent = value.get("percentage")
783
+ plugged = str(value.get("plugged") or "").upper()
784
+ return _block(
785
+ "termux-battery-status",
786
+ percent=int(percent) if isinstance(percent, (int, float)) else None,
787
+ status=str(value.get("status") or "").lower(),
788
+ plugged=plugged.lower(),
789
+ health=str(value.get("health") or "").lower(),
790
+ temperature_c=value.get("temperature"),
791
+ current_ua=value.get("current"),
792
+ ac_online=None if not plugged else plugged != "UNPLUGGED",
793
+ )
794
+
795
+
796
+ def _battery_linux() -> Dict[str, Any]:
797
+ """A Linux host: ``/sys/class/power_supply`` or an honest "no battery"."""
798
+ value = _linux_battery()
799
+ if value is None:
800
+ return _unavailable("no BAT* device under /sys/class/power_supply",
801
+ source="/sys/class/power_supply")
802
+ return _block("/sys/class/power_supply", **value)
803
+
804
+
805
+ def _battery_windows() -> Dict[str, Any]:
806
+ """Windows: ``GetSystemPowerStatus``, and a reason when there is no battery."""
807
+ power = _windows_power_status()
808
+ if not power or power.get("no_battery"):
809
+ return _unavailable("no system battery detected (desktop or virtual machine)",
810
+ source="GetSystemPowerStatus")
811
+ return _block(
812
+ "GetSystemPowerStatus",
813
+ percent=power.get("percent"),
814
+ # GetSystemPowerStatus has no "full" state: with the charger connected
815
+ # and the charging flag clear, the honest answer is "not charging".
816
+ status=("charging" if power.get("charging")
817
+ else ("not charging" if power.get("ac_online") else "discharging")),
818
+ plugged="ac" if power.get("ac_online") else "battery",
819
+ health="",
820
+ temperature_c=None,
821
+ time_remaining_s=power.get("time_remaining_s"),
822
+ ac_online=power.get("ac_online"),
823
+ )
824
+
825
+
826
+ @_runs("pmset")
827
+ def _battery_darwin() -> Dict[str, Any]:
828
+ """A Mac: ``pmset -g batt``, which reports InternalBattery or nothing."""
829
+ value = _darwin_battery()
830
+ if value is None:
831
+ return _unavailable("no battery reported by pmset", source="pmset -g batt")
832
+ return _block("pmset -g batt", **value)
833
+
834
+
835
+ # ---------------------------------------------------------------------------
836
+ # network: interfaces, default route, wifi, cellular
837
+ # ---------------------------------------------------------------------------
838
+
839
+ def _interface_kind(name: str) -> str:
840
+ """Classify an interface from its name *or* its Windows description.
841
+
842
+ Windows reports adapters by marketing name ("Intel(R) Wi-Fi 6 AX201 160MHz"),
843
+ so prefix matching alone is not enough there; the substrings below cover both
844
+ spellings without inventing a kind for an unknown adapter.
845
+ """
846
+ lowered = name.lower()
847
+ if lowered == "lo" or lowered.startswith("lo:"):
848
+ return "loopback"
849
+ if lowered.startswith(("wlan", "wlp", "wifi", "wl")) or any(
850
+ token in lowered for token in ("wi-fi", "wireless", "802.11")):
851
+ return "wifi"
852
+ if lowered.startswith(("rmnet", "ccmni", "pdp", "wwan", "wwp", "usb0", "rndis")) or any(
853
+ token in lowered for token in ("mobile broadband", "cellular", "lte", "5g")):
854
+ return "cellular"
855
+ if lowered.startswith(("eth", "en", "eno", "enp", "ens")) or "ethernet" in lowered:
856
+ return "ethernet"
857
+ if lowered.startswith(("tun", "tap", "wg", "tailscale", "utun", "ppp")) or any(
858
+ token in lowered for token in ("vpn", "wireguard", "openvpn")):
859
+ return "vpn"
860
+ if lowered.startswith(("docker", "veth", "br-", "virbr")) or any(
861
+ token in lowered for token in ("virtual", "hyper-v", "vmware", "loopback")):
862
+ return "virtual"
863
+ return "other"
864
+
865
+
866
+ def _posix_interfaces() -> List[Dict[str, Any]]:
867
+ """Linux and macOS interfaces: ``ip -j`` when it exists, else ``ifconfig -a``.
868
+
869
+ ``ip`` is probed before it is run because macOS has no iproute2: calling it
870
+ there would only add a failing process to every collect.
871
+ """
872
+ interfaces: List[Dict[str, Any]] = []
873
+ rc, text = -1, ""
874
+ if _which("ip"):
875
+ rc, text = _run(["ip", "-j", "addr", "show"], timeout=4.0)
876
+ if rc == 0 and text:
877
+ try:
878
+ for item in json.loads(text):
879
+ if not isinstance(item, dict):
880
+ continue
881
+ ipv4, ipv6 = [], []
882
+ for addr in item.get("addr_info") or []:
883
+ if not isinstance(addr, dict):
884
+ continue
885
+ local = addr.get("local")
886
+ if not local:
887
+ continue
888
+ (ipv4 if addr.get("family") == "inet" else ipv6).append(
889
+ "%s/%s" % (local, addr.get("prefixlen")) if addr.get("prefixlen") is not None
890
+ else str(local))
891
+ name = str(item.get("ifname") or "")
892
+ interfaces.append({
893
+ "name": name,
894
+ "kind": _interface_kind(name),
895
+ "up": str(item.get("operstate") or "").lower() == "up",
896
+ "macs": [str(item.get("address") or "")] if item.get("address") else [],
897
+ "ipv4": ipv4,
898
+ "ipv6": ipv6[:_MAX_LIST // 4],
899
+ })
900
+ return interfaces
901
+ except Exception:
902
+ pass
903
+ # Fallback: ifconfig -a (macOS, older Linux) - names only, plus addresses.
904
+ rc, text = _run(["ifconfig", "-a"], timeout=4.0)
905
+ if rc != 0 or not text:
906
+ return []
907
+ current: Optional[Dict[str, Any]] = None
908
+ for line in text.splitlines():
909
+ head = re.match(r"^([A-Za-z0-9_.:-]+):?\s", line)
910
+ if head and not line.startswith((" ", "\t")):
911
+ current = {"name": head.group(1), "kind": _interface_kind(head.group(1)),
912
+ "up": "UP" in line.upper(), "macs": [], "ipv4": [], "ipv6": []}
913
+ mac = re.search(r"ether\s+([0-9a-f:]{17})", line, re.I)
914
+ if mac:
915
+ current["macs"] = [mac.group(1)]
916
+ interfaces.append(current)
917
+ continue
918
+ if current is None:
919
+ continue
920
+ addr = re.search(r"inet\s+(?:addr:)?(\d+\.\d+\.\d+\.\d+)(?:/\d+)?", line)
921
+ if addr:
922
+ current["ipv4"].append(addr.group(1))
923
+ return interfaces
924
+
925
+
926
+ def _windows_interfaces() -> List[Dict[str, Any]]:
927
+ script = (
928
+ "try { Get-CimInstance Win32_NetworkAdapterConfiguration -Filter 'IPEnabled=TRUE' | "
929
+ "Select-Object Description,MACAddress,IPAddress,DefaultIPGateway | ConvertTo-Json -Compress -Depth 4 } "
930
+ "catch { '' }"
931
+ )
932
+ rc, text = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], timeout=6.0)
933
+ if rc != 0 or not text.strip():
934
+ return []
935
+ try:
936
+ payload = json.loads(text)
937
+ except Exception:
938
+ return []
939
+ if isinstance(payload, dict):
940
+ payload = [payload]
941
+ interfaces: List[Dict[str, Any]] = []
942
+ for item in payload if isinstance(payload, list) else []:
943
+ if not isinstance(item, dict):
944
+ continue
945
+ name = str(item.get("Description") or "")
946
+ ips = item.get("IPAddress") or []
947
+ if isinstance(ips, str):
948
+ ips = [ips]
949
+ ipv4 = [str(ip) for ip in ips if re.match(r"^\d+\.\d+\.\d+\.\d+$", str(ip))]
950
+ ipv6 = [str(ip) for ip in ips if ":" in str(ip)]
951
+ gateways = item.get("DefaultIPGateway") or []
952
+ if isinstance(gateways, str):
953
+ gateways = [gateways]
954
+ interfaces.append({
955
+ "name": name,
956
+ "kind": _interface_kind(name) if _interface_kind(name) != "other" else "ethernet",
957
+ "up": bool(ipv4 or ipv6),
958
+ "macs": [str(item.get("MACAddress"))] if item.get("MACAddress") else [],
959
+ "ipv4": ipv4,
960
+ "ipv6": ipv6[:_MAX_LIST // 4],
961
+ "gateways": [str(g) for g in gateways],
962
+ })
963
+ return interfaces
964
+
965
+
966
+ def _default_route(interfaces: List[Dict[str, Any]]) -> str:
967
+ if scenario() == "windows":
968
+ # Windows has no /proc/net/route; the CIM query already returned the
969
+ # per-adapter gateways.
970
+ for item in interfaces:
971
+ for gateway in item.get("gateways") or []:
972
+ return "%s via %s" % (item.get("name", ""), gateway)
973
+ return ""
974
+ if os.path.isfile("/proc/net/route"):
975
+ for line in _read_text("/proc/net/route").splitlines()[1:]:
976
+ parts = line.split()
977
+ if len(parts) >= 3 and parts[1] == "00000000":
978
+ return parts[0]
979
+ for item in interfaces:
980
+ if item.get("ipv4") and item.get("kind") != "loopback":
981
+ return str(item.get("name") or "")
982
+ return ""
983
+
984
+
985
+ def _wifi_posix() -> Optional[Dict[str, Any]]:
986
+ wireless = _read_text("/proc/net/wireless")
987
+ candidates: List[str] = []
988
+ for line in wireless.splitlines()[2:]:
989
+ name = line.split(":")[0].strip()
990
+ if name:
991
+ candidates.append(name)
992
+ for name in candidates:
993
+ if _which("iw"):
994
+ rc, text = _run(["iw", "dev", name, "link"], timeout=3.0)
995
+ if rc == 0 and "Connected to" in text:
996
+ ssid = re.search(r"SSID:\s*(.+)", text)
997
+ signal = re.search(r"signal:\s*(-?\d+)\s*dBm", text)
998
+ freq = re.search(r"freq:\s*(\d+)", text)
999
+ rate = re.search(r"tx bitrate:\s*([\d.]+)\s*MBit/s", text)
1000
+ bssid = re.search(r"Connected to\s+([0-9a-f:]{17})", text, re.I)
1001
+ return {
1002
+ "ssid": ssid.group(1).strip() if ssid else "",
1003
+ "bssid": bssid.group(1) if bssid else "",
1004
+ "rssi_dbm": int(signal.group(1)) if signal else None,
1005
+ "frequency_mhz": int(freq.group(1)) if freq else None,
1006
+ "link_speed_mbps": float(rate.group(1)) if rate else None,
1007
+ }
1008
+ if _which("nmcli"):
1009
+ rc, text = _run(["nmcli", "-t", "-e", "no", "-f", "ACTIVE,SSID,SIGNAL,FREQ", "dev", "wifi"],
1010
+ timeout=4.0)
1011
+ if rc == 0:
1012
+ for line in text.splitlines():
1013
+ parts = line.split(":")
1014
+ if parts and parts[0].strip() == "yes" and len(parts) >= 3:
1015
+ try:
1016
+ percent = int(parts[len(parts) - 2].strip())
1017
+ except Exception:
1018
+ percent = None
1019
+ return {
1020
+ "ssid": parts[1] if len(parts) > 1 else "",
1021
+ "bssid": "",
1022
+ "rssi_dbm": int(round(percent / 2.0 - 100)) if percent is not None else None,
1023
+ "signal_pct": percent,
1024
+ "frequency_mhz": None,
1025
+ "link_speed_mbps": None,
1026
+ }
1027
+ return None
1028
+
1029
+
1030
+ def _wifi_windows() -> Optional[Dict[str, Any]]:
1031
+ rc, text = _run(["netsh", "wlan", "show", "interfaces"], timeout=5.0)
1032
+ if rc != 0 or not text:
1033
+ return None
1034
+ ssid = bssid = ""
1035
+ percent: Optional[int] = None
1036
+ rate: Optional[float] = None
1037
+ frequency: Optional[int] = None
1038
+ for line in text.splitlines():
1039
+ stripped = line.strip()
1040
+ if "BSSID" in stripped.upper():
1041
+ mac = re.search(r"([0-9a-f]{2}(?::[0-9a-f]{2}){5})", stripped, re.I)
1042
+ if mac:
1043
+ bssid = mac.group(1)
1044
+ continue
1045
+ if re.match(r"^SSID\s*:", stripped, re.I):
1046
+ ssid = stripped.split(":", 1)[1].strip()
1047
+ continue
1048
+ percent_match = re.match(r"^[^:]+:\s*(\d{1,3})\s*%\s*$", stripped)
1049
+ if percent_match and percent is None:
1050
+ percent = int(percent_match.group(1))
1051
+ continue
1052
+ if "Mbps" in stripped:
1053
+ numbers = re.findall(r"(\d+(?:\.\d+)?)", stripped)
1054
+ if numbers and rate is None:
1055
+ rate = float(numbers[0])
1056
+ continue
1057
+ if "MHz" in stripped or "GHz" in stripped:
1058
+ numbers = re.findall(r"(\d+(?:\.\d+)?)", stripped)
1059
+ if numbers:
1060
+ value = float(numbers[0])
1061
+ frequency = int(value * 1000) if "GHz" in stripped else int(value)
1062
+ if not ssid and percent is None:
1063
+ return None
1064
+ return {
1065
+ "ssid": ssid,
1066
+ "bssid": bssid,
1067
+ "rssi_dbm": int(round(percent / 2.0 - 100)) if percent is not None else None,
1068
+ "signal_pct": percent,
1069
+ "frequency_mhz": frequency,
1070
+ "link_speed_mbps": rate,
1071
+ }
1072
+
1073
+
1074
+ def _wifi_darwin() -> Optional[Dict[str, Any]]:
1075
+ airport = ("/System/Library/PrivateFrameworks/Apple80211.framework/"
1076
+ "Versions/Current/Resources/airport")
1077
+ rc, text = _run([airport, "-I"], timeout=4.0)
1078
+ if rc != 0 or not text:
1079
+ return None
1080
+ if "AirPort: Off" in text:
1081
+ return None
1082
+ def field(name: str) -> Optional[str]:
1083
+ match = re.search(r"^\s*%s:\s*(.+)$" % re.escape(name), text, re.M)
1084
+ return match.group(1).strip() if match else None
1085
+ rssi = field("agrCtlRSSI")
1086
+ rate = field("lastTxRate")
1087
+ channel = field("channel")
1088
+ frequency = None
1089
+ if channel:
1090
+ try:
1091
+ frequency = int(float(channel.split(",")[0]) * 1000) or None
1092
+ except Exception:
1093
+ frequency = None
1094
+ return {
1095
+ "ssid": field("SSID") or "",
1096
+ "bssid": field("BSSID") or "",
1097
+ "rssi_dbm": int(rssi) if rssi and re.match(r"^-?\d+$", rssi) else None,
1098
+ "frequency_mhz": frequency,
1099
+ "link_speed_mbps": float(rate) if rate and re.match(r"^[\d.]+$", rate) else None,
1100
+ }
1101
+
1102
+
1103
+ @_runs("termux-wifi-connectioninfo")
1104
+ def _wifi_termux() -> Optional[Dict[str, Any]]:
1105
+ """A phone: Android exposes the associated network through Termux:API."""
1106
+ result = _termux_json("termux-wifi-connectioninfo", timeout=4.0)
1107
+ if not result.get("ok"):
1108
+ return None
1109
+ value = result.get("value")
1110
+ if not isinstance(value, dict):
1111
+ return None
1112
+ rssi = value.get("rssi")
1113
+ if isinstance(rssi, (int, float)) and rssi in (0, -1, -127):
1114
+ rssi = None
1115
+ ssid = str(value.get("ssid") or "")
1116
+ if ssid in ("<unknown ssid>", "unknown"):
1117
+ ssid = ""
1118
+ return {
1119
+ "ssid": ssid,
1120
+ "bssid": str(value.get("bssid") or ""),
1121
+ "rssi_dbm": int(rssi) if isinstance(rssi, (int, float)) else None,
1122
+ # The command answers "frequency_mhz" (verified on an OPPO PHY110, Android
1123
+ # 16); "frequency" stays as the fallback for older builds.
1124
+ "frequency_mhz": value.get("frequency_mhz", value.get("frequency")),
1125
+ "link_speed_mbps": value.get("link_speed_mbps"),
1126
+ "ip": value.get("ip"),
1127
+ "network_id": value.get("network_id"),
1128
+ }
1129
+
1130
+
1131
+ def _cell_level(dbm: Optional[int]) -> Optional[int]:
1132
+ """Android's 0-4 bars, derived the way the framework does it for LTE."""
1133
+ if dbm is None:
1134
+ return None
1135
+ if dbm >= -85:
1136
+ return 4
1137
+ if dbm >= -95:
1138
+ return 3
1139
+ if dbm >= -105:
1140
+ return 2
1141
+ if dbm >= -115:
1142
+ return 1
1143
+ return 0
1144
+
1145
+
1146
+ @_runs("termux-telephony-deviceinfo", "termux-telephony-cellinfo")
1147
+ def _cellular_termux() -> Dict[str, Any]:
1148
+ """Cellular state, which only Android can report without root.
1149
+
1150
+ A desktop has no such source at all, which is why no other scenario has an
1151
+ implementation that could answer this: their network blocks carry
1152
+ ``cellular: None``.
1153
+ """
1154
+ block: Dict[str, Any] = {}
1155
+ device = _termux_json("termux-telephony-deviceinfo", timeout=4.0)
1156
+ if device.get("ok") and isinstance(device.get("value"), dict):
1157
+ value = device["value"]
1158
+ block.update({
1159
+ "operator": value.get("network_operator_name") or value.get("sim_operator_name") or "",
1160
+ "network_type": value.get("network_type") or "",
1161
+ "roaming": value.get("network_roaming"),
1162
+ "data_state": value.get("data_state"),
1163
+ "sim_state": value.get("sim_state"),
1164
+ "phone_type": value.get("phone_type"),
1165
+ })
1166
+ cells = _termux_json("termux-telephony-cellinfo", timeout=5.0)
1167
+ if cells.get("ok"):
1168
+ value = cells.get("value")
1169
+ if isinstance(value, dict):
1170
+ value = [value]
1171
+ registered = [c for c in (value or []) if isinstance(c, dict) and c.get("registered")]
1172
+ pool = registered or [c for c in (value or []) if isinstance(c, dict)]
1173
+ best_dbm: Optional[int] = None
1174
+ best_level: Optional[int] = None
1175
+ for cell in pool:
1176
+ candidates = [cell.get("dbm")]
1177
+ for key in ("rsrp", "rssi", "level"):
1178
+ if isinstance(cell.get(key), (int, float)):
1179
+ candidates.append(cell.get(key))
1180
+ for candidate in candidates:
1181
+ if isinstance(candidate, (int, float)) and -160 < candidate < 0:
1182
+ dbm = int(candidate)
1183
+ if best_dbm is None or dbm > best_dbm:
1184
+ best_dbm = dbm
1185
+ break
1186
+ if isinstance(cell.get("level"), (int, float)) and 0 <= cell["level"] <= 4:
1187
+ best_level = max(best_level or 0, int(cell["level"]))
1188
+ if best_dbm is not None:
1189
+ block["signal_dbm"] = best_dbm
1190
+ if best_level is not None:
1191
+ block["level"] = best_level
1192
+ elif best_dbm is not None:
1193
+ block["level"] = _cell_level(best_dbm)
1194
+ block["cells_seen"] = len(value or [])
1195
+ block["cell_type"] = (pool[0].get("type") if pool else "") or ""
1196
+ elif cells.get("reason"):
1197
+ block["cellinfo_reason"] = cells["reason"]
1198
+ if cells.get("fix"):
1199
+ block["cellinfo_fix"] = cells["fix"]
1200
+ return block
1201
+
1202
+
1203
+ def _signal_summary(wifi: Optional[Dict[str, Any]], cellular: Dict[str, Any],
1204
+ interfaces: Optional[List[Dict[str, Any]]] = None,
1205
+ connected_kind: str = "") -> str:
1206
+ """One line a model can quote: the link that is actually carrying traffic.
1207
+
1208
+ The primary link wins. A phone that is on Wi-Fi is not described by its LTE
1209
+ bars: reporting "Magti LTE -101 dBm" while ``connected_kind`` says ``wifi``
1210
+ made the two fields contradict each other, and the model quotes whichever it
1211
+ reads first. The other radio is not lost - it stays in the ``wifi`` and
1212
+ ``cellular`` blocks.
1213
+ """
1214
+ cellular_line = ""
1215
+ if cellular.get("signal_dbm") is not None:
1216
+ cellular_line = "%s %s %d dBm (level %s)" % (
1217
+ cellular.get("operator") or "cellular", cellular.get("network_type") or "",
1218
+ cellular["signal_dbm"], cellular.get("level"))
1219
+ wifi_line = ""
1220
+ if wifi and wifi.get("rssi_dbm") is not None:
1221
+ wifi_line = "wifi %s %d dBm" % (wifi.get("ssid") or "(hidden)", wifi["rssi_dbm"])
1222
+ elif wifi:
1223
+ wifi_line = "wifi %s (signal not reported by Android)" % (wifi.get("ssid") or "(hidden)")
1224
+ if connected_kind == "wifi" and wifi_line:
1225
+ return wifi_line
1226
+ if connected_kind == "cellular" and cellular_line:
1227
+ return cellular_line
1228
+ if wifi_line or cellular_line:
1229
+ return wifi_line or cellular_line
1230
+ for item in interfaces or []:
1231
+ if item.get("up") and item.get("kind") in ("ethernet", "vpn", "other"):
1232
+ return "%s (%s)" % (item.get("name", ""), item.get("kind", ""))
1233
+ return "unknown"
1234
+
1235
+
1236
+ def _network_result(interfaces: List[Dict[str, Any]], wifi: Optional[Dict[str, Any]],
1237
+ cellular: Dict[str, Any], source: str) -> Dict[str, Any]:
1238
+ """The block every scenario returns, so no scenario invents its own shape."""
1239
+ route = _default_route(interfaces)
1240
+ # A cellular dict that carries only ``cellinfo_reason``/``cellinfo_fix`` is an
1241
+ # explanation, not a network: counting it as one produced "available: true,
1242
+ # interface_count: 0" on a phone whose every radio call had timed out.
1243
+ has_cellular = bool(cellular.get("network_type") or cellular.get("signal_dbm"))
1244
+ if not interfaces and not wifi and not has_cellular:
1245
+ return _unavailable("no network interface could be inspected", source=source,
1246
+ cellular=cellular or None)
1247
+ connected_kind = "wifi" if wifi else ("cellular" if has_cellular else "")
1248
+ if not connected_kind:
1249
+ for item in interfaces:
1250
+ if item.get("up") and item.get("kind") != "loopback":
1251
+ connected_kind = str(item.get("kind"))
1252
+ break
1253
+ return _block(
1254
+ source,
1255
+ interfaces=interfaces[:_MAX_LIST],
1256
+ interface_count=len(interfaces),
1257
+ default_route=route,
1258
+ connected_kind=connected_kind,
1259
+ wifi=wifi,
1260
+ cellular=cellular or None,
1261
+ signal=_signal_summary(wifi, cellular, interfaces, connected_kind),
1262
+ )
1263
+
1264
+
1265
+ @_runs("ip", "ifconfig", "termux-wifi-connectioninfo", "termux-telephony-deviceinfo",
1266
+ "termux-telephony-cellinfo")
1267
+ def _network_termux() -> Dict[str, Any]:
1268
+ """A phone: Termux:API for the radios, /proc and ip/ifconfig for the links."""
1269
+ return _network_result(_posix_interfaces(), _wifi_termux(), _cellular_termux(),
1270
+ "termux-api + ip/ifconfig")
1271
+
1272
+
1273
+ @_runs("ip", "ifconfig", "iw", "nmcli")
1274
+ def _network_linux() -> Dict[str, Any]:
1275
+ """A Linux host: ip/ifconfig for the links, iw/nmcli for Wi-Fi. No cellular."""
1276
+ return _network_result(_posix_interfaces(), _wifi_posix(), {},
1277
+ "ip/ifconfig + iw/nmcli")
1278
+
1279
+
1280
+ @_runs("powershell", "netsh")
1281
+ def _network_windows() -> Dict[str, Any]:
1282
+ """Windows: CIM for the adapters and gateways, netsh for the Wi-Fi link."""
1283
+ return _network_result(_windows_interfaces(), _wifi_windows(), {},
1284
+ "Win32_NetworkAdapterConfiguration + netsh")
1285
+
1286
+
1287
+ @_runs("ifconfig", "airport")
1288
+ def _network_darwin() -> Dict[str, Any]:
1289
+ """macOS: ifconfig for the links, the Apple80211 airport helper for Wi-Fi."""
1290
+ return _network_result(_posix_interfaces(), _wifi_darwin(), {},
1291
+ "ifconfig -a + airport")
1292
+
1293
+
1294
+ # ---------------------------------------------------------------------------
1295
+ # locale and time
1296
+ # ---------------------------------------------------------------------------
1297
+
1298
+ def _split_locale(tag: str) -> Tuple[str, str]:
1299
+ """``ru-RU.UTF-8`` -> ``("ru", "RU")``; tolerant of ``C``/``POSIX``/empty."""
1300
+ if not tag:
1301
+ return "", ""
1302
+ tag = tag.split("@", 1)[0]
1303
+ tag = tag.split(".", 1)[0]
1304
+ if tag.upper() in ("C", "POSIX"):
1305
+ return "", ""
1306
+ parts = re.split(r"[-_]", tag)
1307
+ language = parts[0].lower() if parts and parts[0] else ""
1308
+ region = ""
1309
+ for part in parts[1:]:
1310
+ if len(part) in (2, 3) and part.isalpha():
1311
+ region = part.upper()
1312
+ break
1313
+ return language, region
1314
+
1315
+
1316
+ def _windows_locale() -> str:
1317
+ try:
1318
+ import ctypes
1319
+ buffer = ctypes.create_unicode_buffer(85)
1320
+ if ctypes.windll.kernel32.GetUserDefaultLocaleName(buffer, 85):
1321
+ return buffer.value
1322
+ except Exception:
1323
+ pass
1324
+ return ""
1325
+
1326
+
1327
+ def _locale_block(platform_tag: str = "", platform_source: str = "") -> Dict[str, Any]:
1328
+ """Locale from the environment, with one platform-provided tag taking priority.
1329
+
1330
+ Every scenario goes through here; only the tag the platform can add differs,
1331
+ so the parsing and the "no locale is configured" answer exist once.
1332
+ """
1333
+ tags: List[str] = []
1334
+ for key in ("LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"):
1335
+ value = os.environ.get(key)
1336
+ if value:
1337
+ tags.extend(part for part in str(value).split(":") if part)
1338
+ source = "environment"
1339
+ if platform_tag:
1340
+ tags.insert(0, platform_tag)
1341
+ source = "%s + environment" % platform_source
1342
+ language, region = "", ""
1343
+ for tag in tags:
1344
+ language, region = _split_locale(tag)
1345
+ if language:
1346
+ break
1347
+ python_locale: Tuple[Optional[str], Optional[str]] = (None, None)
1348
+ try:
1349
+ python_locale = _locale.getlocale() # ('ru_RU', 'UTF-8') or (None, None)
1350
+ except Exception:
1351
+ python_locale = (None, None)
1352
+ if not language and python_locale[0]:
1353
+ language, region = _split_locale(python_locale[0] or "")
1354
+ source = "locale.getlocale"
1355
+ if not language:
1356
+ return _unavailable("no locale is configured (LANG/LC_* unset)",
1357
+ source=source or "environment", languages=tags[:_MAX_LIST // 3])
1358
+ return _block(
1359
+ source,
1360
+ language=language,
1361
+ region=region,
1362
+ languages=tags[:_MAX_LIST // 3],
1363
+ encoding=python_locale[1] or os.environ.get("LC_CTYPE", "").split(".")[-1] or "",
1364
+ )
1365
+
1366
+
1367
+ @_runs("getprop")
1368
+ def _locale_termux() -> Dict[str, Any]:
1369
+ """A phone: Android keeps the locale in a system property, not in LANG."""
1370
+ prop = _android_getprop("persist.sys.locale") or _android_getprop("ro.product.locale")
1371
+ return _locale_block(prop, "getprop")
1372
+
1373
+
1374
+ def _locale_windows() -> Dict[str, Any]:
1375
+ """Windows: the user's default locale name comes from the Win32 API."""
1376
+ return _locale_block(_windows_locale(), "GetUserDefaultLocaleName")
1377
+
1378
+
1379
+ def _locale_linux() -> Dict[str, Any]:
1380
+ """A Linux host: LANG/LC_* are the whole answer."""
1381
+ return _locale_block("", "environment")
1382
+
1383
+
1384
+ def _locale_darwin() -> Dict[str, Any]:
1385
+ """macOS: it keeps the locale in the same POSIX variables, so no probe.
1386
+
1387
+ ``defaults read -g AppleLocale`` would be one more subprocess for a value
1388
+ LANG or ``locale.getlocale()`` already carries.
1389
+ """
1390
+ return _locale_block("", "environment")
1391
+
1392
+
1393
+ def _posix_timezone_name() -> str:
1394
+ """The tz name on a Linux/macOS host, from the files the tz database updates.
1395
+
1396
+ ``/etc/timezone`` is Debian's copy, ``/var/db/zoneinfo`` is macOS's, and the
1397
+ ``/etc/localtime`` symlink is what every other Linux distribution has.
1398
+ """
1399
+ for candidate in ("/etc/timezone", "/var/db/zoneinfo"):
1400
+ text = _read_text(candidate)
1401
+ if text and "/" in text:
1402
+ return text
1403
+ try:
1404
+ link = os.path.realpath("/etc/localtime")
1405
+ if "zoneinfo/" in link:
1406
+ return link.split("zoneinfo/", 1)[1]
1407
+ except Exception:
1408
+ pass
1409
+ return ""
1410
+
1411
+
1412
+ def _tz_name_fallback() -> str:
1413
+ """Last resort: the C library's own abbreviation (``UTC``, ``MSK``, ``MSD``)."""
1414
+ try:
1415
+ return str(time.tzname[0] or "")
1416
+ except Exception:
1417
+ return ""
1418
+
1419
+
1420
+ @_runs("getprop")
1421
+ def _time_termux() -> Dict[str, Any]:
1422
+ """A phone: Android keeps the zone in a system property; an app has no /etc."""
1423
+ return _time_block(_android_getprop("persist.sys.timezone") or _posix_timezone_name()
1424
+ or _tz_name_fallback())
1425
+
1426
+
1427
+ def _time_windows() -> Dict[str, Any]:
1428
+ """Windows: the IANA-ish key lives in the registry, not in a file."""
1429
+ name = _windows_registry(
1430
+ r"SYSTEM\CurrentControlSet\Control\TimeZoneInformation", "TimeZoneKeyName")
1431
+ return _time_block(name or _tz_name_fallback())
1432
+
1433
+
1434
+ def _time_linux() -> Dict[str, Any]:
1435
+ """A Linux host: /etc/timezone or the /etc/localtime symlink."""
1436
+ return _time_block(_posix_timezone_name() or _tz_name_fallback())
1437
+
1438
+
1439
+ def _time_darwin() -> Dict[str, Any]:
1440
+ """macOS: /var/db/zoneinfo is the authoritative copy."""
1441
+ return _time_block(_posix_timezone_name() or _tz_name_fallback())
1442
+
1443
+
1444
+ def _time_block(timezone_name: str = "") -> Dict[str, Any]:
1445
+ now = datetime.now()
1446
+ local = now.astimezone()
1447
+ offset = local.utcoffset()
1448
+ offset_seconds = int(offset.total_seconds()) if offset is not None else 0
1449
+ sign = "+" if offset_seconds >= 0 else "-"
1450
+ absolute = abs(offset_seconds)
1451
+ return _block(
1452
+ "datetime.astimezone + tz database",
1453
+ iso_local=local.isoformat(timespec="seconds"),
1454
+ utc=datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
1455
+ epoch=int(time.time()),
1456
+ utc_offset="%s%02d:%02d" % (sign, absolute // 3600, (absolute % 3600) // 60),
1457
+ utc_offset_minutes=offset_seconds // 60,
1458
+ timezone=timezone_name,
1459
+ abbreviation=local.tzname() or "",
1460
+ weekday=local.strftime("%A"),
1461
+ time_24h=local.strftime("%H:%M:%S"),
1462
+ )
1463
+
1464
+
1465
+ # ---------------------------------------------------------------------------
1466
+ # hardware: cpu, memory, thermal
1467
+ # ---------------------------------------------------------------------------
1468
+
1469
+ def _android_soc_name() -> str:
1470
+ """The SoC name from Android's own properties.
1471
+
1472
+ A phone's ``/proc/cpuinfo`` carries only per-core ``processor : N`` lines - no
1473
+ "Hardware", no "model name" - verified on an OPPO PHY110 running Android 16. A
1474
+ parser that accepts "Processor" therefore returns the literal ``"0"``. Android
1475
+ 12+ answers through ``ro.soc.model``; older builds through ``ro.board.platform``
1476
+ or ``ro.hardware``.
1477
+ """
1478
+ for prop in ("ro.soc.model", "ro.board.platform", "ro.hardware", "ro.product.board"):
1479
+ value = _android_getprop(prop).strip()
1480
+ if value:
1481
+ return value
1482
+ return ""
1483
+
1484
+
1485
+ def _posix_cpu_model() -> str:
1486
+ info = _read_text("/proc/cpuinfo")
1487
+ # "processor : 0" is a core index, not a model: a value of digits only is
1488
+ # skipped, which is what made a phone report its CPU as "0".
1489
+ for key in ("model name", "Hardware", "cpu model", "Model", "Processor"):
1490
+ match = re.search(r"^%s\s*:\s*(.+)$" % re.escape(key), info, re.M | re.I)
1491
+ if match:
1492
+ value = match.group(1).strip()
1493
+ if value and not value.isdigit():
1494
+ return value
1495
+ if is_termux():
1496
+ soc = _android_soc_name()
1497
+ if soc:
1498
+ return soc
1499
+ return platform.processor() or platform.machine()
1500
+
1501
+
1502
+ def _windows_cpu_model() -> str:
1503
+ return _windows_registry(
1504
+ r"HARDWARE\DESCRIPTION\System\CentralProcessor\0", "ProcessorNameString")
1505
+
1506
+
1507
+ def _cpu_block(model: str, source: str,
1508
+ sensors: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
1509
+ """The CPU fields every scenario shares; only the model and the source differ.
1510
+
1511
+ ``sensors`` is the thermal list a CPU temperature is read from: Windows and
1512
+ macOS pass an empty list because neither has sysfs, and a temperature that
1513
+ does not exist must stay ``None`` instead of being guessed.
1514
+ """
1515
+ frequencies: List[float] = []
1516
+ for path in sorted(_glob.glob("/sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq"))[:_MAX_LIST]:
1517
+ value = _read_int(path)
1518
+ if value:
1519
+ frequencies.append(round(value / 1000.0, 1)) # kHz -> MHz
1520
+ max_freq = None
1521
+ for path in sorted(_glob.glob("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")):
1522
+ value = _read_int(path)
1523
+ if value:
1524
+ max_freq = round(value / 1000.0, 1)
1525
+ cores = len(_glob.glob("/sys/devices/system/cpu/cpu[0-9]*"))
1526
+ zones = _thermal_sensors() if sensors is None else sensors
1527
+ temp = max((item["value"] for item in zones if isinstance(item.get("value"), (int, float))),
1528
+ default=None)
1529
+ load: Dict[str, float] = {}
1530
+ try:
1531
+ if hasattr(os, "getloadavg"):
1532
+ values = os.getloadavg()
1533
+ load = {"1m": round(values[0], 2), "5m": round(values[1], 2), "15m": round(values[2], 2)}
1534
+ except Exception:
1535
+ load = {}
1536
+ if not load:
1537
+ try:
1538
+ from core.vitals import get_host_vitals
1539
+ load = dict(get_host_vitals().get("cpu_load") or {})
1540
+ except Exception:
1541
+ load = {}
1542
+ return _block(
1543
+ source,
1544
+ model=model,
1545
+ cores=cores or os.cpu_count() or 0,
1546
+ logical=os.cpu_count() or 0,
1547
+ freq_mhz_cur=frequencies,
1548
+ freq_mhz_max=max_freq,
1549
+ load=load,
1550
+ temp_c=temp,
1551
+ )
1552
+
1553
+
1554
+ def _parse_meminfo(text: str) -> Dict[str, float]:
1555
+ """The four numbers this module needs out of ``/proc/meminfo``, in MB."""
1556
+ values: Dict[str, int] = {}
1557
+ for line in (text or "").splitlines():
1558
+ parts = line.split(":")
1559
+ if len(parts) < 2:
1560
+ continue
1561
+ raw = parts[1].strip().split()
1562
+ if not raw:
1563
+ continue
1564
+ try:
1565
+ values[parts[0].strip()] = int(raw[0])
1566
+ except Exception:
1567
+ continue
1568
+ to_mb = lambda kb: round(kb / 1024.0, 1) # noqa: E731 - one expression, used four times
1569
+ return {
1570
+ "total_mb": to_mb(values.get("MemTotal", 0)),
1571
+ "available_mb": to_mb(values.get("MemAvailable", 0)),
1572
+ "swap_total_mb": to_mb(values.get("SwapTotal", 0)),
1573
+ "swap_free_mb": to_mb(values.get("SwapFree", 0)),
1574
+ }
1575
+
1576
+
1577
+ def _memory_block() -> Dict[str, Any]:
1578
+ """RAM and swap. ``core.vitals`` owns the per-OS maths; /proc is the fallback.
1579
+
1580
+ A phone reports through the same Linux path, so reusing the existing collector
1581
+ keeps one implementation of the Windows/macOS branches instead of a second copy
1582
+ here. The collector is not trusted blindly: on Android with Python 3.13+ it used
1583
+ to answer 0 MB (``platform.system()`` is "Android", which it did not know), and a
1584
+ phone that reports "0.0/0.0 MB" is worse than one that says it cannot tell. So a
1585
+ zero total is re-read from ``/proc/meminfo`` - through the module's own reader, so
1586
+ a simulated phone in the tests takes the same path as the real one.
1587
+ """
1588
+ ram: Dict[str, Any] = {}
1589
+ source = "core.vitals"
1590
+ try:
1591
+ from core.vitals import get_host_vitals
1592
+ ram = get_host_vitals().get("ram") or {}
1593
+ except Exception:
1594
+ ram = {}
1595
+ total_mb = float(ram.get("total_mb") or 0.0)
1596
+ free_mb = float(ram.get("free_mb") or 0.0)
1597
+ used_mb = float(ram.get("used_mb") or 0.0)
1598
+ used_pct = float(ram.get("used_pct") or 0.0)
1599
+
1600
+ proc = _parse_meminfo(_read_text("/proc/meminfo"))
1601
+ if total_mb <= 0 and proc["total_mb"] > 0:
1602
+ total_mb = proc["total_mb"]
1603
+ free_mb = proc["available_mb"]
1604
+ used_mb = max(0.0, round(total_mb - free_mb, 1))
1605
+ used_pct = round(used_mb / total_mb * 100.0, 1) if total_mb else 0.0
1606
+ source = "/proc/meminfo"
1607
+ if total_mb <= 0:
1608
+ return _unavailable("no memory source reported a total on this platform",
1609
+ source=source or "core.vitals")
1610
+ fields: Dict[str, Any] = {
1611
+ "total_mb": total_mb,
1612
+ "used_mb": used_mb,
1613
+ "free_mb": free_mb,
1614
+ "used_pct": used_pct,
1615
+ }
1616
+ # Swap only exists on Linux and Android, and the same read answers it.
1617
+ if proc["swap_total_mb"] > 0:
1618
+ fields["swap_total_mb"] = proc["swap_total_mb"]
1619
+ fields["swap_free_mb"] = proc["swap_free_mb"]
1620
+ return _block(source, **fields)
1621
+
1622
+
1623
+ #: Substrings of a thermal-zone type that mean "this zone is a temperature".
1624
+ #: A phone exposes far more zones than fit into a block, so when the list is cut
1625
+ #: the named temperatures win over the odd ones (levels, unpopulated radios).
1626
+ _THERMAL_NAME_HINTS = ("temp", "tsens", "cpuss", "cpu", "gpu", "soc", "battery",
1627
+ "skin", "charger", "quiet", "therm", "pa", "usb")
1628
+
1629
+
1630
+ def _thermal_priority(name: str) -> int:
1631
+ """0 for a name that looks like a temperature, 1 for everything else."""
1632
+ lowered = name.lower()
1633
+ return 0 if any(hint in lowered for hint in _THERMAL_NAME_HINTS) else 1
1634
+
1635
+
1636
+ def _zone_index(path: str) -> int:
1637
+ """The number at the end of a ``thermal_zoneN`` path, for a natural order.
1638
+
1639
+ ``sorted()`` on the raw paths puts ``thermal_zone10`` before
1640
+ ``thermal_zone9``; the kernel numbers them, and the report should read in
1641
+ that order.
1642
+ """
1643
+ match = re.search(r"(\d+)$", os.path.basename(path))
1644
+ return int(match.group(1)) if match else 0
1645
+
1646
+
1647
+ def _looks_like_a_level(name: str) -> bool:
1648
+ """True for a zone that reports a *level*, not a temperature.
1649
+
1650
+ ``pm8550-bcl-lvl0`` and ``pm8550b-ibat-lvl0`` on an OPPO PHY110 report ``0``
1651
+ and ``93``: battery current limits, not 0 °C and 0.093 °C. Reporting either as
1652
+ a temperature is exactly the kind of fabricated value this module exists to
1653
+ avoid.
1654
+ """
1655
+ lowered = name.lower()
1656
+ return "lvl" in lowered or "bcl" in lowered
1657
+
1658
+
1659
+ def _celsius(millidegrees: Optional[int]) -> Optional[float]:
1660
+ """One sysfs reading as °C, or ``None`` when it cannot be a temperature.
1661
+
1662
+ sysfs reports millidegrees on phones and most PCs, but whole degrees on a few
1663
+ drivers, and an unpopulated zone reports ``-273000``. ``None`` means "say
1664
+ nothing"; it never becomes the number 0.
1665
+ """
1666
+ if millidegrees is None:
1667
+ return None
1668
+ value = (round(millidegrees / 1000.0, 1) if abs(millidegrees) > 1000
1669
+ else float(millidegrees))
1670
+ if not -100.0 < value < 200.0:
1671
+ return None # -273: not populated; 200+ : not a phone
1672
+ return value
1673
+
1674
+
1675
+ def _thermal_sensors() -> List[Dict[str, Any]]:
1676
+ """Thermal zones (then hwmon chips) as temperatures, in degrees Celsius.
1677
+
1678
+ Three things a real OPPO PHY110 taught us that the sysfs documentation does
1679
+ not say:
1680
+
1681
+ * some zones are populated with ``-273000`` (``mmw0``, the ``epm*`` family,
1682
+ ``bcl_warn``) - reporting that as a temperature poisons any ``max()`` a
1683
+ model computes, so an impossible reading is dropped, never converted;
1684
+ * ``pm8550-bcl-lvl*`` are battery current-limit *levels* whose ``0`` is not
1685
+ 0 °C, so a zone whose type names a level is skipped;
1686
+ * the phone exposes 107 zones and a block holds :data:`_MAX_LIST`, so the
1687
+ zones whose names look like temperatures survive the cut, and two zones
1688
+ that share one label (``usb-therm`` twice) are reported once.
1689
+ """
1690
+ zones: List[Tuple[int, int, str, str]] = []
1691
+ for zone in _glob.glob("/sys/class/thermal/thermal_zone*"):
1692
+ name = _read_text(os.path.join(zone, "type")) or os.path.basename(zone)
1693
+ zones.append((_thermal_priority(name), _zone_index(zone), name, zone))
1694
+ # Stable within a priority, so the kernel's own zone order decides between
1695
+ # two temperatures instead of the alphabet.
1696
+ zones.sort(key=lambda item: (item[0], item[1]))
1697
+
1698
+ selected: List[Tuple[str, str]] = []
1699
+ seen = set()
1700
+ for _priority, _index, name, zone in zones:
1701
+ if name in seen or _looks_like_a_level(name):
1702
+ continue # a repeated label, or a "zone" that is a current limit
1703
+ seen.add(name)
1704
+ selected.append((name, zone))
1705
+ if len(selected) >= _MAX_LIST:
1706
+ break
1707
+
1708
+ found: List[Dict[str, Any]] = []
1709
+ for name, zone in selected:
1710
+ value = _celsius(_read_int(os.path.join(zone, "temp")))
1711
+ if value is None:
1712
+ continue
1713
+ found.append({"name": name, "value": value, "unit": "C", "source": "sysfs"})
1714
+ if found:
1715
+ return found
1716
+ for hwmon in sorted(_glob.glob("/sys/class/hwmon/hwmon*"))[:4]:
1717
+ chip = _read_text(os.path.join(hwmon, "name")) or os.path.basename(hwmon)
1718
+ for path in sorted(_glob.glob(os.path.join(hwmon, "temp*_input")))[:4]:
1719
+ value = _celsius(_read_int(path))
1720
+ if value is None:
1721
+ continue
1722
+ found.append({"name": chip, "value": value, "unit": "C", "source": "hwmon"})
1723
+ return found[:_MAX_LIST]
1724
+
1725
+
1726
+ def _hardware_termux() -> Dict[str, Any]:
1727
+ """A phone: Android is Linux underneath, so /proc, cpufreq and thermal answer."""
1728
+ sensors = _thermal_sensors()
1729
+ return _block(
1730
+ "/proc/cpuinfo + /proc/meminfo + thermal",
1731
+ cpu=_cpu_block(_posix_cpu_model(), "/proc/cpuinfo + cpufreq + thermal",
1732
+ sensors=sensors),
1733
+ memory=_memory_block(),
1734
+ thermal=sensors,
1735
+ )
1736
+
1737
+
1738
+ def _hardware_linux() -> Dict[str, Any]:
1739
+ """A Linux host: the same files as a phone, with a x86 model name."""
1740
+ sensors = _thermal_sensors()
1741
+ return _block(
1742
+ "/proc/cpuinfo + /proc/meminfo + thermal",
1743
+ cpu=_cpu_block(_posix_cpu_model(), "/proc/cpuinfo + cpufreq", sensors=sensors),
1744
+ memory=_memory_block(),
1745
+ thermal=sensors,
1746
+ )
1747
+
1748
+
1749
+ def _hardware_windows() -> Dict[str, Any]:
1750
+ """Windows: the CPU name comes from the registry, and there is no sysfs."""
1751
+ cpu = _cpu_block(_windows_cpu_model() or platform.processor(), "winreg + vitals",
1752
+ sensors=[])
1753
+ # Windows has no sysfs: a CPU temperature is not available without a vendor
1754
+ # driver, and guessing one would be worse than saying so.
1755
+ cpu["temp_c"] = None
1756
+ cpu["temp_note"] = "not exposed without a vendor driver"
1757
+ return _block("ctypes + winreg + core.vitals", cpu=cpu, memory=_memory_block(),
1758
+ thermal=[])
1759
+
1760
+
1761
+ @_runs("sysctl")
1762
+ def _hardware_darwin() -> Dict[str, Any]:
1763
+ """macOS: sysctl names the CPU; a temperature would need root (powermetrics)."""
1764
+ _rc, model = _run(["sysctl", "-n", "machdep.cpu.brand_string"], timeout=2.0)
1765
+ cpu = _cpu_block(model or platform.processor(), "sysctl + vitals", sensors=[])
1766
+ cpu["temp_c"] = None
1767
+ cpu["temp_note"] = "powermetrics needs root"
1768
+ return _block("sysctl + core.vitals", cpu=cpu, memory=_memory_block(), thermal=[])
1769
+
1770
+
1771
+ # ---------------------------------------------------------------------------
1772
+ # storage
1773
+ # ---------------------------------------------------------------------------
1774
+
1775
+ def capture_dir() -> str:
1776
+ """Where camera/microphone captures are written.
1777
+
1778
+ Explicit configuration wins, then shared storage on a phone (only after
1779
+ ``termux-setup-storage``), then the node's own cache - never ``/sdcard``
1780
+ before the operator has granted it.
1781
+ """
1782
+ if _CAPTURE_DIR:
1783
+ return _CAPTURE_DIR
1784
+ env = os.environ.get("MESH_DEVICE_CAPTURE_DIR")
1785
+ if env:
1786
+ return os.path.expanduser(env)
1787
+ if scenario() == "termux":
1788
+ shared = os.path.expanduser("~/storage/dcim")
1789
+ if os.path.isdir(shared):
1790
+ return os.path.join(shared, "antigravity-mesh")
1791
+ shared = os.path.expanduser("~/storage/shared")
1792
+ if os.path.isdir(shared):
1793
+ return os.path.join(shared, "AntigravityMesh")
1794
+ return os.path.join(os.path.expanduser("~"), ".cache", "antigravity-mesh", "captures")
1795
+
1796
+
1797
+ def _storage_fields(root: str) -> Dict[str, Any]:
1798
+ """Disk usage of one filesystem root, plus the paths a capture is written to."""
1799
+ try:
1800
+ usage = shutil.disk_usage(root)
1801
+ total_gb = round(usage.total / (1024 ** 3), 2)
1802
+ free_gb = round(usage.free / (1024 ** 3), 2)
1803
+ used_pct = round(usage.used / usage.total * 100.0, 1) if usage.total else 0.0
1804
+ except Exception:
1805
+ total_gb = free_gb = used_pct = 0.0
1806
+ return {
1807
+ "root": root,
1808
+ "total_gb": total_gb,
1809
+ "free_gb": free_gb,
1810
+ "used_pct": used_pct,
1811
+ "home": os.path.expanduser("~"),
1812
+ "capture_dir": capture_dir(),
1813
+ }
1814
+
1815
+
1816
+ def _storage_termux() -> Dict[str, Any]:
1817
+ """A phone: the same disk, plus whether the shared-storage grant exists."""
1818
+ fields = _storage_fields("/")
1819
+ shared = os.path.expanduser("~/storage/shared")
1820
+ granted = os.path.isdir(shared)
1821
+ fields["shared"] = shared if granted else "absent"
1822
+ fields["prefix"] = os.environ.get("PREFIX", "")
1823
+ if not granted:
1824
+ fields["shared_fix"] = ("run `termux-setup-storage` in Termux and grant the "
1825
+ "permission to reach photos, downloads and documents")
1826
+ return _block("shutil.disk_usage + termux-setup-storage", **fields)
1827
+
1828
+
1829
+ def _storage_linux() -> Dict[str, Any]:
1830
+ """A Linux host: one root filesystem and the per-user capture directory."""
1831
+ return _block("shutil.disk_usage", **_storage_fields("/"))
1832
+
1833
+
1834
+ def _storage_windows() -> Dict[str, Any]:
1835
+ """Windows: the system drive, not whichever drive the process sits on."""
1836
+ return _block("shutil.disk_usage", **_storage_fields("C:\\"))
1837
+
1838
+
1839
+ def _storage_darwin() -> Dict[str, Any]:
1840
+ """macOS: one root filesystem, like Linux."""
1841
+ return _block("shutil.disk_usage", **_storage_fields("/"))
1842
+
1843
+
1844
+ # ---------------------------------------------------------------------------
1845
+ # cameras, microphones, sensors
1846
+ # ---------------------------------------------------------------------------
1847
+
1848
+ def _normalise_camera(item: Dict[str, Any]) -> Dict[str, Any]:
1849
+ sizes = item.get("jpeg_output_sizes") or []
1850
+ best = None
1851
+ for size in sizes if isinstance(sizes, list) else []:
1852
+ if not isinstance(size, dict):
1853
+ continue
1854
+ width, height = size.get("width"), size.get("height")
1855
+ if isinstance(width, int) and isinstance(height, int):
1856
+ if best is None or width * height > best[0] * best[1]:
1857
+ best = (width, height)
1858
+ camera: Dict[str, Any] = {
1859
+ "id": item.get("id"),
1860
+ "facing": item.get("facing") or "",
1861
+ }
1862
+ if best:
1863
+ camera["max_resolution"] = "%dx%d" % best
1864
+ if item.get("focal_lengths"):
1865
+ camera["focal_lengths"] = item.get("focal_lengths")
1866
+ if sizes:
1867
+ camera["modes"] = len(sizes) if isinstance(sizes, list) else None
1868
+ return camera
1869
+
1870
+
1871
+ @_runs("termux-camera-info")
1872
+ def _cameras_termux() -> Dict[str, Any]:
1873
+ """A phone: the Termux:API app lists the cameras and their modes."""
1874
+ result = _termux_json("termux-camera-info", timeout=5.0)
1875
+ if not result.get("ok"):
1876
+ fields = {"reason": result.get("reason") or "termux-camera-info did not answer"}
1877
+ if result.get("fix"):
1878
+ fields["fix"] = result["fix"]
1879
+ return _unavailable(source="termux-camera-info", **fields)
1880
+ value = result.get("value")
1881
+ items = value if isinstance(value, list) else ([value] if isinstance(value, dict) else [])
1882
+ cameras = [_normalise_camera(item) for item in items if isinstance(item, dict)]
1883
+ return _block("termux-camera-info", cameras=cameras, count=len(cameras))
1884
+
1885
+
1886
+ @_runs("powershell")
1887
+ def _cameras_windows() -> Dict[str, Any]:
1888
+ """Windows: plug-and-play entities of class Camera/Image, via CIM."""
1889
+ script = ("try { Get-CimInstance Win32_PnPEntity -Filter \"PNPClass='Camera' or "
1890
+ "PNPClass='Image'\" | Select-Object -ExpandProperty Name } catch { '' }")
1891
+ rc, text = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
1892
+ timeout=6.0)
1893
+ names = [line.strip() for line in text.splitlines() if line.strip()] if rc == 0 else []
1894
+ if not names:
1895
+ return _unavailable("no camera reported by Win32_PnPEntity (PNPClass Camera/Image)",
1896
+ source="Get-CimInstance Win32_PnPEntity")
1897
+ return _block("Get-CimInstance Win32_PnPEntity",
1898
+ cameras=[{"id": index, "name": name, "facing": ""}
1899
+ for index, name in enumerate(names)],
1900
+ count=len(names))
1901
+
1902
+
1903
+ @_runs("system_profiler")
1904
+ def _cameras_darwin() -> Dict[str, Any]:
1905
+ """macOS: system_profiler's camera section, one indented model line each."""
1906
+ rc, text = _run(["system_profiler", "SPCameraDataType", "-detailLevel", "mini"],
1907
+ timeout=6.0)
1908
+ if rc != 0 or not text:
1909
+ return _unavailable("system_profiler did not answer",
1910
+ source="system_profiler SPCameraDataType")
1911
+ names = []
1912
+ for line in text.splitlines():
1913
+ stripped = line.strip()
1914
+ # Model lines are indented under the "Camera"/"iSight" group and end
1915
+ # in a colon; the group headers themselves are unindented.
1916
+ if stripped.endswith(":") and (line.startswith(" ") or line.startswith("\t")):
1917
+ names.append(stripped.rstrip(":"))
1918
+ names = names[:4]
1919
+ if not names:
1920
+ return _unavailable("no camera found by system_profiler",
1921
+ source="system_profiler SPCameraDataType")
1922
+ return _block("system_profiler SPCameraDataType",
1923
+ cameras=[{"id": index, "name": name, "facing": ""}
1924
+ for index, name in enumerate(names)],
1925
+ count=len(names))
1926
+
1927
+
1928
+ def _cameras_linux() -> Dict[str, Any]:
1929
+ """A Linux host: video4linux devices under /sys, no subprocess needed."""
1930
+ cameras: List[Dict[str, Any]] = []
1931
+ for path in sorted(_glob.glob("/sys/class/video4linux/video*"))[:_MAX_LIST]:
1932
+ name = _read_text(os.path.join(path, "name"))
1933
+ index = _read_int(os.path.join(path, "index"))
1934
+ cameras.append({"id": index, "device": os.path.basename(path), "name": name,
1935
+ "facing": ""})
1936
+ if not cameras:
1937
+ return _unavailable("no /sys/class/video4linux device", source="/sys/class/video4linux")
1938
+ return _block("/sys/class/video4linux", cameras=cameras, count=len(cameras))
1939
+
1940
+
1941
+ def _microphones_termux() -> Dict[str, Any]:
1942
+ """A phone: Android gives apps no capture-device enumeration at all.
1943
+
1944
+ This is a real platform limit, not a missing probe - the fix points at the
1945
+ recording action, which does work - so the block says so instead of trying a
1946
+ Linux tool that does not exist on Android.
1947
+ """
1948
+ return _unavailable(
1949
+ "Android does not expose capture-device enumeration to apps",
1950
+ source="android",
1951
+ fix=("record through device_capture(action=mic_record_start): the Termux:API app "
1952
+ "must be installed and granted the microphone permission"))
1953
+
1954
+
1955
+ @_runs("powershell")
1956
+ def _microphones_windows() -> Dict[str, Any]:
1957
+ """Windows: capture endpoints are audio-endpoint plug-and-play entities."""
1958
+ script = ("try { Get-CimInstance Win32_PnPEntity -Filter \"PNPClass='AudioEndpoint'\" | "
1959
+ "Where-Object { $_.DeviceID -like '*0.0.1.*' } | "
1960
+ "Select-Object -ExpandProperty Name } catch { '' }")
1961
+ rc, text = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
1962
+ timeout=6.0)
1963
+ names = [line.strip() for line in text.splitlines() if line.strip()] if rc == 0 else []
1964
+ if not names:
1965
+ return _unavailable("no capture endpoint reported (Win32_PnPEntity AudioEndpoint)",
1966
+ source="Get-CimInstance Win32_PnPEntity")
1967
+ return _block("Get-CimInstance Win32_PnPEntity (capture endpoints)",
1968
+ microphones=[{"name": name, "kind": "input"} for name in names],
1969
+ count=len(names))
1970
+
1971
+
1972
+ @_runs("system_profiler")
1973
+ def _microphones_darwin() -> Dict[str, Any]:
1974
+ """macOS: system_profiler's audio section, one input device per line."""
1975
+ rc, text = _run(["system_profiler", "SPAudioDataType", "-detailLevel", "mini"],
1976
+ timeout=6.0)
1977
+ if rc != 0 or not text:
1978
+ return _unavailable("system_profiler did not answer",
1979
+ source="system_profiler SPAudioDataType")
1980
+ names = [line.strip() for line in text.splitlines()
1981
+ if "Input" in line and line.strip().endswith(":")]
1982
+ names = [name.rstrip(":") for name in names][:4]
1983
+ if not names:
1984
+ return _unavailable("no input device found by system_profiler",
1985
+ source="system_profiler SPAudioDataType")
1986
+ return _block("system_profiler SPAudioDataType",
1987
+ microphones=[{"name": name, "kind": "input"} for name in names],
1988
+ count=len(names))
1989
+
1990
+
1991
+ @_runs("arecord")
1992
+ def _microphones_linux() -> Dict[str, Any]:
1993
+ """A Linux host: ALSA's card list, with ``arecord -l`` as the detailed one."""
1994
+ mics: List[Dict[str, Any]] = []
1995
+ rc, text = _run(["arecord", "-l"], timeout=3.0)
1996
+ if rc == 0 and text:
1997
+ for line in text.splitlines():
1998
+ match = re.match(r"^card\s+(\d+):\s*([^\[]+)\[([^\]]+)\],\s*device\s+(\d+):\s*(.+)$",
1999
+ line)
2000
+ if match:
2001
+ mics.append({"card": int(match.group(1)), "device": int(match.group(4)),
2002
+ "name": match.group(3).strip(), "kind": "input"})
2003
+ if not mics:
2004
+ for card in re.finditer(r"^\s*(\d+)\s*\[([^\]]+)\]:\s*(\S+)\s*-\s*(.+)$",
2005
+ _read_text("/proc/asound/cards"), re.M):
2006
+ mics.append({"card": int(card.group(1)), "name": card.group(4).strip(),
2007
+ "driver": card.group(3), "kind": "input"})
2008
+ if not mics:
2009
+ return _unavailable("no ALSA capture device (/proc/asound/cards empty)",
2010
+ source="/proc/asound/cards")
2011
+ return _block("/proc/asound/cards + arecord -l", microphones=mics[:_MAX_LIST],
2012
+ count=len(mics))
2013
+
2014
+
2015
+ def _sensor_names(result: Dict[str, Any]) -> List[str]:
2016
+ """Sensor names out of a ``termux-sensor -l`` answer (list or object)."""
2017
+ value = result.get("value")
2018
+ if isinstance(value, dict):
2019
+ value = value.get("sensors") or value.get("list") or list(value.keys())
2020
+ names: List[str] = []
2021
+ for item in value if isinstance(value, list) else []:
2022
+ if isinstance(item, str):
2023
+ names.append(item)
2024
+ elif isinstance(item, dict):
2025
+ name = item.get("name") or item.get("type")
2026
+ if name:
2027
+ names.append(str(name))
2028
+ return names[:_MAX_LIST]
2029
+
2030
+
2031
+ @_runs("termux-sensor")
2032
+ def _sensors_termux(sample: Optional[str] = None) -> Dict[str, Any]:
2033
+ """A phone: Termux:API lists the hardware sensors, and samples one on request.
2034
+
2035
+ Names are free; a value can cost battery, which is why nothing here samples
2036
+ unless the caller asked for exactly one sensor by name.
2037
+ """
2038
+ result = _termux_json("termux-sensor", ["-l"], timeout=6.0)
2039
+ if not result.get("ok"):
2040
+ fields = {"reason": result.get("reason") or "termux-sensor did not answer"}
2041
+ if result.get("fix"):
2042
+ fields["fix"] = result["fix"]
2043
+ return _unavailable(source="termux-sensor -l", **fields)
2044
+ names = _sensor_names(result)
2045
+ if sample:
2046
+ adapter = _termux_adapter()
2047
+ timed = (adapter.sensor_sample(sample) if adapter is not None
2048
+ else _termux_json("termux-sensor", ["-s", sample, "-n", "1"], timeout=8.0))
2049
+ if isinstance(timed, dict) and timed.get("ok") is False:
2050
+ return _unavailable(timed.get("reason") or "sensor %s did not answer" % sample,
2051
+ source="termux-sensor -s %s -n 1" % sample,
2052
+ sensors=names, sampling="names")
2053
+ value = timed.get("value") if isinstance(timed, dict) else timed
2054
+ return _block("termux-sensor -s %s -n 1" % sample, sensors=names,
2055
+ sampling="values", values=value)
2056
+ return _block("termux-sensor -l", sensors=names, count=len(names), sampling="names")
2057
+
2058
+
2059
+ def _sensors_linux(sample: Optional[str] = None) -> Dict[str, Any]:
2060
+ """A Linux host: thermal zones and hwmon chips are the only sensor source.
2061
+
2062
+ ``sample`` is accepted and ignored: a sysfs zone is already a live value, and
2063
+ there is no per-sensor sampling verb here.
2064
+ """
2065
+ thermal = _thermal_sensors()
2066
+ if thermal:
2067
+ return _block("sysfs thermal + hwmon", sensors=thermal, count=len(thermal),
2068
+ sampling="values")
2069
+ return _unavailable("no thermal zone or hwmon device", source="/sys/class/thermal")
2070
+
2071
+
2072
+ def _sensors_windows(sample: Optional[str] = None) -> Dict[str, Any]:
2073
+ """Windows: thermal and hwmon are Linux concepts - there is no equivalent."""
2074
+ return _unavailable("no sensor interface (thermal/hwmon is a Linux/Android concept)",
2075
+ source="windows")
2076
+
2077
+
2078
+ def _sensors_darwin(sample: Optional[str] = None) -> Dict[str, Any]:
2079
+ """macOS: no sysfs at all, and a temperature would need root (powermetrics)."""
2080
+ return _unavailable("macOS exposes no sensor source without root (powermetrics)",
2081
+ source="macos")
2082
+
2083
+
2084
+ # ---------------------------------------------------------------------------
2085
+ # capabilities
2086
+ # ---------------------------------------------------------------------------
2087
+
2088
+ def _capabilities_common() -> Dict[str, Any]:
2089
+ """The two fields every scenario reports, before the platform's own list."""
2090
+ return {"platform": _SYSTEM, "device_class": _classify(_battery_present())}
2091
+
2092
+
2093
+ def _wifi_tools() -> Dict[str, bool]:
2094
+ """Which Wi-Fi tools this host has. A PATH probe, never a command."""
2095
+ return {tool: bool(_which(tool)) for tool in ("iw", "nmcli", "netsh", "airport")}
2096
+
2097
+
2098
+ def _sources_without_sysfs() -> Dict[str, Any]:
2099
+ """The source list for a platform that has no sysfs (Windows, macOS).
2100
+
2101
+ ``None`` means "this platform has no such source", which is different from
2102
+ ``False`` ("the source exists and found nothing") - a distinction the model
2103
+ needs before it suggests a camera probe.
2104
+ """
2105
+ return {"battery": bool(_battery_present()), "cameras": None, "microphones": None,
2106
+ "thermal": False, "wifi_tools": _wifi_tools()}
2107
+
2108
+
2109
+ def _capabilities_termux() -> Dict[str, Any]:
2110
+ """A phone: the ``termux-*`` commands that exist plus the permissions proven.
2111
+
2112
+ The local inventory is what ``core.termux`` would report, kept here so a
2113
+ checkout without that file still tells the model which phone APIs it can
2114
+ call. **The canonical key is ``termux_api``** in this section and in
2115
+ :func:`_device_termux`; the adapter files the same fact under ``api``, and it
2116
+ is renamed right here - one fact, one name, so a model that reads
2117
+ ``termux_api`` can never be told "no termux-api" because the answer was put
2118
+ under the other key.
2119
+ """
2120
+ fields = _capabilities_common()
2121
+ adapter = _termux_adapter()
2122
+ if adapter is not None:
2123
+ try:
2124
+ fields.update(adapter.capabilities())
2125
+ except Exception as exc:
2126
+ fields["termux_api"] = {"error": "%s: %s" % (type(exc).__name__, exc)}
2127
+ if "api" in fields:
2128
+ fields["termux_api"] = fields.pop("api")
2129
+ if "termux_api" not in fields:
2130
+ # The subset of core/termux.REQUIRED_COMMANDS this module can use; the
2131
+ # full inventory stays in one place, the adapter.
2132
+ commands = ("termux-battery-status", "termux-wifi-connectioninfo",
2133
+ "termux-telephony-deviceinfo", "termux-telephony-cellinfo",
2134
+ "termux-sensor", "termux-camera-info",
2135
+ "termux-microphone-record", "termux-location", "termux-notification",
2136
+ "termux-clipboard-get", "termux-volume", "termux-tts-speak",
2137
+ "termux-torch", "termux-vibrate", "termux-wake-lock")
2138
+ fields["termux_api"] = {
2139
+ "installed": bool(_which("termux-battery-status")),
2140
+ "commands": {name: bool(_which(name)) for name in commands},
2141
+ "fix": TERMUX_API_FIX,
2142
+ }
2143
+ if "storage_permission" not in fields:
2144
+ fields["storage_permission"] = os.path.isdir(os.path.expanduser("~/storage/shared"))
2145
+ if "wake_lock" not in fields:
2146
+ # The adapter owns ``wake_lock`` and it is a dict ({"command","held",
2147
+ # "count"}) - a plain bool here used to overwrite it and lose the detail.
2148
+ # Without the adapter the only honest wake-lock fact is whether the
2149
+ # command exists, so it gets a name of its own instead.
2150
+ fields["wake_lock_command"] = bool(_which("termux-wake-lock"))
2151
+ return _block("local probes", **fields)
2152
+
2153
+
2154
+ def _capabilities_windows() -> Dict[str, Any]:
2155
+ """Windows: cameras and microphones come from CIM, thermal has no source."""
2156
+ fields = _capabilities_common()
2157
+ fields["sources"] = _sources_without_sysfs()
2158
+ return _block("local probes", **fields)
2159
+
2160
+
2161
+ def _capabilities_linux() -> Dict[str, Any]:
2162
+ """A Linux host: every source is a file under ``/sys`` or ``/proc``."""
2163
+ fields = _capabilities_common()
2164
+ fields["sources"] = {
2165
+ "battery": bool(_battery_present()),
2166
+ "cameras": bool(_glob.glob("/sys/class/video4linux/video*")),
2167
+ "microphones": os.path.isfile("/proc/asound/cards"),
2168
+ "thermal": bool(_glob.glob("/sys/class/thermal/thermal_zone*")),
2169
+ "wifi_tools": _wifi_tools(),
2170
+ }
2171
+ return _block("local probes", **fields)
2172
+
2173
+
2174
+ @_runs("pmset")
2175
+ def _capabilities_darwin() -> Dict[str, Any]:
2176
+ """macOS: cameras and microphones come from system_profiler, thermal needs root.
2177
+
2178
+ The ``pmset`` declaration is here for :func:`_battery_present`, which is the
2179
+ ``device_class`` probe inside this block.
2180
+ """
2181
+ fields = _capabilities_common()
2182
+ fields["sources"] = _sources_without_sysfs()
2183
+ return _block("local probes", **fields)
2184
+
2185
+
2186
+ # ---------------------------------------------------------------------------
2187
+ # The matrix: one implementation per section per scenario
2188
+ # ---------------------------------------------------------------------------
2189
+
2190
+ #: Section -> scenario -> the implementation that runs. This is the platform
2191
+ #: contract of the module in one place: reading ``_SECTION_IMPL["battery"]``
2192
+ #: answers "what runs on a phone" without following a single ``if``, and a
2193
+ #: scenario that has no source for a section gets an implementation that says so
2194
+ #: (with the missing platform capability named) instead of borrowing another
2195
+ #: platform's answer.
2196
+ #:
2197
+ #: Every entry is zero-argument except ``sensors``, which also accepts
2198
+ #: ``sample=<name>`` when the caller asked for one live reading.
2199
+ _SECTION_IMPL: Dict[str, Dict[str, Callable[..., Dict[str, Any]]]] = {
2200
+ "device": {"termux": _device_termux, "linux": _device_linux,
2201
+ "windows": _device_windows, "darwin": _device_darwin},
2202
+ "battery": {"termux": _battery_termux, "linux": _battery_linux,
2203
+ "windows": _battery_windows, "darwin": _battery_darwin},
2204
+ "network": {"termux": _network_termux, "linux": _network_linux,
2205
+ "windows": _network_windows, "darwin": _network_darwin},
2206
+ "locale": {"termux": _locale_termux, "linux": _locale_linux,
2207
+ "windows": _locale_windows, "darwin": _locale_darwin},
2208
+ "time": {"termux": _time_termux, "linux": _time_linux,
2209
+ "windows": _time_windows, "darwin": _time_darwin},
2210
+ "hardware": {"termux": _hardware_termux, "linux": _hardware_linux,
2211
+ "windows": _hardware_windows, "darwin": _hardware_darwin},
2212
+ "storage": {"termux": _storage_termux, "linux": _storage_linux,
2213
+ "windows": _storage_windows, "darwin": _storage_darwin},
2214
+ "cameras": {"termux": _cameras_termux, "linux": _cameras_linux,
2215
+ "windows": _cameras_windows, "darwin": _cameras_darwin},
2216
+ "microphones": {"termux": _microphones_termux, "linux": _microphones_linux,
2217
+ "windows": _microphones_windows, "darwin": _microphones_darwin},
2218
+ "sensors": {"termux": _sensors_termux, "linux": _sensors_linux,
2219
+ "windows": _sensors_windows, "darwin": _sensors_darwin},
2220
+ "capabilities": {"termux": _capabilities_termux, "linux": _capabilities_linux,
2221
+ "windows": _capabilities_windows, "darwin": _capabilities_darwin},
2222
+ }
2223
+
2224
+
2225
+ def scenario_commands(case: str) -> List[str]:
2226
+ """The external commands scenario ``case`` is allowed to run for one collect().
2227
+
2228
+ Derived from the implementations themselves (the ``@_runs`` declarations
2229
+ attached to the very functions in ``_SECTION_IMPL``), never from a second
2230
+ hand-written list, so it cannot drift from the code that runs: an
2231
+ implementation that starts calling a new tool without declaring it fails
2232
+ ``tests/test_device_scenarios.py``.
2233
+ """
2234
+ found: List[str] = []
2235
+ for name in ALL_SECTIONS:
2236
+ impl = _SECTION_IMPL.get(name, {}).get(case)
2237
+ for command in getattr(impl, "commands", ()) or ():
2238
+ if command not in found:
2239
+ found.append(command)
2240
+ return sorted(found)
2241
+
2242
+
2243
+ def matrix_problems() -> List[str]:
2244
+ """Sections x scenarios with no implementation, or a name that is not a section.
2245
+
2246
+ Cheap, side-effect free, and meant to be called from CI and from the tests: a
2247
+ section added without four implementations must fail the build, not a phone
2248
+ at 3 a.m.
2249
+ """
2250
+ problems: List[str] = []
2251
+ for name in sorted(_SECTION_IMPL):
2252
+ if name not in ALL_SECTIONS:
2253
+ problems.append("%s: not a section of this module" % name)
2254
+ for name in ALL_SECTIONS:
2255
+ row = _SECTION_IMPL.get(name) or {}
2256
+ for case in SCENARIOS:
2257
+ impl = row.get(case)
2258
+ if impl is None:
2259
+ problems.append("%s: no implementation for scenario %s" % (name, case))
2260
+ elif not callable(impl):
2261
+ problems.append("%s/%s: %r is not callable" % (name, case, impl))
2262
+ return problems
2263
+
2264
+
2265
+ # ---------------------------------------------------------------------------
2266
+ # public API
2267
+ # ---------------------------------------------------------------------------
2268
+
2269
+ def _resolve_sections(sections: Optional[Iterable[str]]) -> List[str]:
2270
+ if sections is None:
2271
+ return list(SECTIONS)
2272
+ if isinstance(sections, str):
2273
+ sections = [sections]
2274
+ wanted: List[str] = []
2275
+ for name in sections:
2276
+ name = str(name).strip().lower()
2277
+ if name == "all":
2278
+ return list(ALL_SECTIONS)
2279
+ if name in ALL_SECTIONS and name not in wanted:
2280
+ wanted.append(name)
2281
+ return wanted
2282
+
2283
+
2284
+ def _collect_one(name: str, kwargs: Dict[str, Any]) -> Dict[str, Any]:
2285
+ """One section through the matrix: ``_SECTION_IMPL[name][scenario()]``."""
2286
+ case = scenario()
2287
+ row = _SECTION_IMPL.get(name)
2288
+ if row is None:
2289
+ return _unavailable("unknown section %r" % name, source="device.collect")
2290
+ impl = row.get(case)
2291
+ if impl is None:
2292
+ # An impossible pair is still an answer, never a KeyError: the contract
2293
+ # is that nothing in this module raises.
2294
+ return _unavailable("no implementation for scenario %r" % case,
2295
+ source="device.%s" % name)
2296
+ sensor = kwargs.get("sensor")
2297
+ if name == "sensors" and sensor:
2298
+ # A named sample is never cached: the caller asked for a live value.
2299
+ return _safe_call(lambda: impl(sample=str(sensor)), source=name)
2300
+ return _cached(name, impl, kwargs.get("fresh", False))
2301
+
2302
+
2303
+ def collect(sections: Optional[Iterable[str]] = None,
2304
+ deadline: float = DEFAULT_DEADLINE,
2305
+ fresh: bool = False,
2306
+ sensor: Optional[str] = None,
2307
+ quick: bool = False) -> Dict[str, Dict[str, Any]]:
2308
+ """Collect the requested sections, in parallel, under one deadline.
2309
+
2310
+ ``quick=True`` drops the slow sections (network, cameras, microphones,
2311
+ sensors): that is what ``MESH_DEVICE_QUICK=1`` asks for, so a status call
2312
+ stays instant on a busy host. A section that misses the deadline comes back
2313
+ as ``available: False`` with ``reason: "timed out"`` - never as a zero.
2314
+ """
2315
+ names = _resolve_sections(sections)
2316
+ if quick:
2317
+ names = [name for name in names if name not in SLOW_SECTIONS]
2318
+ if not names:
2319
+ return {}
2320
+ kwargs = {"fresh": fresh, "sensor": sensor}
2321
+ deadline = max(0.2, float(deadline))
2322
+ started = time.monotonic()
2323
+ results: Dict[str, Dict[str, Any]] = {}
2324
+ if len(names) == 1 or deadline <= 0.5:
2325
+ for name in names:
2326
+ results[name] = _collect_one(name, kwargs)
2327
+ return results
2328
+ pool = ThreadPoolExecutor(max_workers=min(6, len(names)),
2329
+ thread_name_prefix="device-collect")
2330
+ try:
2331
+ futures = {name: pool.submit(_collect_one, name, kwargs) for name in names}
2332
+ for name, future in futures.items():
2333
+ remaining = deadline - (time.monotonic() - started)
2334
+ try:
2335
+ results[name] = future.result(timeout=max(0.05, remaining))
2336
+ except Exception:
2337
+ results[name] = _unavailable(
2338
+ "timed out after %.1fs" % deadline, source=name)
2339
+ finally:
2340
+ pool.shutdown(wait=False)
2341
+ return results
2342
+
2343
+
2344
+ def summary(sections: Optional[Iterable[str]] = None, quick: bool = False) -> Dict[str, Any]:
2345
+ """A compact one-line-per-fact view, for models that read prose poorly.
2346
+
2347
+ Kept deliberately small: it is derived from :func:`collect`, never a second
2348
+ set of probes.
2349
+ """
2350
+ blocks = collect(sections=sections, quick=quick)
2351
+ device = blocks.get("device") or {}
2352
+ battery = blocks.get("battery") or {}
2353
+ network = blocks.get("network") or {}
2354
+ time_block = blocks.get("time") or {}
2355
+ hardware = blocks.get("hardware") or {}
2356
+ lines: List[str] = []
2357
+ if device.get("available"):
2358
+ lines.append("device: %s %s (%s)" % (device.get("manufacturer", ""),
2359
+ device.get("model", ""),
2360
+ device.get("class", "")))
2361
+ if battery.get("available"):
2362
+ lines.append("battery: %s%% %s (%s)" % (battery.get("percent"),
2363
+ battery.get("status", ""),
2364
+ battery.get("plugged", "")))
2365
+ if network.get("available"):
2366
+ lines.append("network: %s; route %s" % (network.get("signal", "unknown"),
2367
+ network.get("default_route", "")))
2368
+ if time_block.get("available"):
2369
+ lines.append("time: %s %s (%s)" % (time_block.get("iso_local"), time_block.get("timezone"),
2370
+ time_block.get("utc_offset")))
2371
+ cpu = (hardware.get("cpu") or {}) if hardware.get("available") else {}
2372
+ memory = (hardware.get("memory") or {}) if hardware.get("available") else {}
2373
+ if cpu:
2374
+ lines.append("cpu: %s, %s cores, load %s, temp %s" % (
2375
+ cpu.get("model", ""), cpu.get("cores"), cpu.get("load"), cpu.get("temp_c")))
2376
+ if memory:
2377
+ lines.append("memory: %s/%s MB used (%s%%)" % (memory.get("used_mb"),
2378
+ memory.get("total_mb"),
2379
+ memory.get("used_pct")))
2380
+ return {"available": bool(lines), "source": "device.summary", "lines": lines,
2381
+ "device_class": device.get("class", ""),
2382
+ "sections": sorted(blocks.keys())}
2383
+
2384
+
2385
+ def main(argv: Optional[Sequence[str]] = None) -> int:
2386
+ """``python -m core.device [section ...]`` - the probe a phone debug run uses."""
2387
+ argv = list(argv if argv is not None else sys.argv[1:])
2388
+ sections = argv or ["all"]
2389
+ payload = collect(sections=sections, fresh=True)
2390
+ # The resolver's own answer travels with the report: a bug report from a
2391
+ # phone that "reports the wrong thing" is what this block is for.
2392
+ payload["_scenario"] = scenario_report()
2393
+ payload["_meta"] = {"collected_at": datetime.now().astimezone().isoformat(timespec="seconds"),
2394
+ "termux": is_termux(), "platform": _SYSTEM}
2395
+ print(json.dumps(payload, indent=2, ensure_ascii=False, default=str))
2396
+ return 0
2397
+
2398
+
2399
+ if __name__ == "__main__": # pragma: no cover
2400
+ raise SystemExit(main())