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.
- package/README.md +733 -16
- package/bin/cli.js +112 -17
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/agent.cpython-313.pyc +0 -0
- package/core/__pycache__/code_cache.cpython-313.pyc +0 -0
- package/core/__pycache__/mcp_tools.cpython-313.pyc +0 -0
- package/core/__pycache__/schemas.cpython-313.pyc +0 -0
- package/core/__pycache__/server.cpython-313.pyc +0 -0
- package/core/__pycache__/vitals.cpython-313.pyc +0 -0
- package/core/__pycache__/web_share.cpython-313.pyc +0 -0
- package/core/agent.py +503 -18
- package/core/agent_harness.py +9 -2
- package/core/code_cache.py +15 -3
- package/core/device.py +2400 -0
- package/core/domain.py +303 -0
- package/core/mcp_tools.py +1840 -112
- package/core/policies.py +35 -5
- package/core/server.py +78 -32
- package/core/termux.py +916 -0
- package/core/updater.py +1713 -0
- package/core/version.py +31 -0
- package/core/vitals.py +66 -2
- package/core/web_share.py +814 -0
- package/gateway.py +1469 -98
- package/install-gui.cmd +40 -0
- package/install-gui.ps1 +1882 -0
- package/install-gui.strings.json +211 -0
- package/install.ps1 +960 -92
- package/install.sh +1214 -81
- package/ops/doctor.ps1 +735 -0
- package/ops/mesh-domain.sh +86 -0
- package/ops/nginx/README.md +77 -0
- package/ops/nginx/antigravity-mesh-mcp.conf +172 -0
- package/ops/nginx/mesh-domain.vhost.template +58 -0
- package/ops/nginx/render-domain.sh +123 -0
- package/ops/update.ps1 +373 -0
- package/ops/windows/README.md +298 -0
- package/ops/windows/agent-watchdog.ps1 +575 -0
- package/ops/windows/run-hidden.vbs +64 -0
- package/package.json +6 -2
- 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())
|