primocode 10.14.1 → 10.15.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/LICENSES/robo.txt +19 -0
- package/README.md +6 -2
- package/bin/primocode.js +54 -3
- package/lib/act.js +1 -0
- package/lib/conta.js +1 -0
- package/lib/estudio/exportar.js +1 -0
- package/lib/mcp.js +1 -0
- package/lib/preparar.js +2 -0
- package/lib/tools.js +1 -0
- package/lib/voz-ponte.js +15 -0
- package/lib/voz.js +99 -5
- package/package.json +3 -2
- package/voz/primo_voz/__main__.py +429 -57
- package/voz/primo_voz/app.py +350 -0
- package/voz/primo_voz/appmac.py +124 -0
- package/voz/primo_voz/assets/mascote.svg +1 -0
- package/voz/primo_voz/conversa.py +236 -0
- package/voz/primo_voz/escuta.py +56 -4
- package/voz/primo_voz/fala.py +87 -0
- package/voz/primo_voz/hud.html +584 -0
- package/voz/primo_voz/hud.py +275 -0
- package/voz/primo_voz/identidade.py +88 -0
- package/voz/primo_voz/luz.py +137 -0
- package/voz/primo_voz/painel.py +459 -0
- package/voz/primo_voz/painel_tui.py +26 -0
- package/voz/primo_voz/sons.py +96 -0
- package/voz/primo_voz/tela.py +319 -0
- package/voz/primo_voz/transcrever.py +13 -2
- package/voz/primo_voz/vendor/RobotExpressive.glb +0 -0
- package/voz/primo_voz/vendor/primo-icone.png +0 -0
- package/voz/primo_voz/vendor/spline-runtime.min.js +8219 -0
- package/voz/primo_voz/vendor/three-robo.min.js +3824 -0
- package/voz/primo_voz/vendor/three.LICENSE.txt +21 -0
- package/voz/primo_voz/vivo.py +34 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""painel.py — o que o HUD mostra, sem desenhar nada.
|
|
2
|
+
|
|
3
|
+
`Painel.aplicar(msg)` recebe uma linha do protocolo (JSON já lido) e atualiza o
|
|
4
|
+
quadro; hud.py (Mac, PyObjC) e `renderizar_tui` (qualquer terminal) só
|
|
5
|
+
DESENHAM este quadro. Assim a regra mora num lugar e se testa sem tela.
|
|
6
|
+
|
|
7
|
+
Mensagens aceitas (todas opcionais, uma por linha):
|
|
8
|
+
{"estado": "ouvindo|pensando|falando|executando|dormindo|interrompido|esperando", "nivel": 0..1}
|
|
9
|
+
{"transcricao": "texto", "parcial": true}
|
|
10
|
+
{"resposta": "texto do painel"}
|
|
11
|
+
{"info": {"projeto": "...", "branch": "...", "tokens": "12k", "plano": "..."}}
|
|
12
|
+
{"painel": "esconder" | "mostrar" | "alternar"}
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from typing import Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
ROTULOS = {
|
|
25
|
+
"esperando": "PRONTO", "dormindo": "EM ESPERA", "ouvindo": "OUVINDO", "pensando": "PENSANDO",
|
|
26
|
+
"falando": "FALANDO", "executando": "EXECUTANDO", "interrompido": "INTERROMPIDO",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Painel:
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self.estado = "esperando"
|
|
33
|
+
self.nivel = 0.0
|
|
34
|
+
self.transcricao = ""
|
|
35
|
+
self.parcial = False
|
|
36
|
+
self.resposta = ""
|
|
37
|
+
self.nome = "Primo"
|
|
38
|
+
self.visivel = True
|
|
39
|
+
# só o que dá para pegar do computador da pessoa (ver coletar_tudo)
|
|
40
|
+
self.info: Dict[str, str] = {"tocando": "", "app": "", "wifi": "", "clima": "", "evento": ""}
|
|
41
|
+
self.sistema: Dict[str, Optional[float]] = {"cpu": None, "mem": None, "disco": None, "bat": None}
|
|
42
|
+
|
|
43
|
+
def aplicar(self, msg: dict) -> None:
|
|
44
|
+
if "estado" in msg and msg["estado"]:
|
|
45
|
+
self.estado = str(msg["estado"])
|
|
46
|
+
if "nivel" in msg and msg["nivel"] is not None:
|
|
47
|
+
self.nivel = max(0.0, min(1.0, float(msg["nivel"])))
|
|
48
|
+
if "transcricao" in msg:
|
|
49
|
+
self.transcricao = str(msg["transcricao"] or "")
|
|
50
|
+
self.parcial = bool(msg.get("parcial"))
|
|
51
|
+
if not self.parcial and self.transcricao:
|
|
52
|
+
self.resposta = "" # pedido novo: a resposta velha sai
|
|
53
|
+
if "resposta" in msg:
|
|
54
|
+
self.resposta = str(msg["resposta"] or "")
|
|
55
|
+
if msg.get("nome"):
|
|
56
|
+
self.nome = str(msg["nome"])
|
|
57
|
+
if isinstance(msg.get("info"), dict):
|
|
58
|
+
for k, v in msg["info"].items():
|
|
59
|
+
if k in self.info:
|
|
60
|
+
self.info[k] = str(v)
|
|
61
|
+
p = msg.get("painel")
|
|
62
|
+
if p == "esconder":
|
|
63
|
+
self.visivel = False
|
|
64
|
+
elif p == "mostrar":
|
|
65
|
+
self.visivel = True
|
|
66
|
+
elif p == "alternar":
|
|
67
|
+
self.visivel = not self.visivel
|
|
68
|
+
if msg.get("esconder"):
|
|
69
|
+
self.visivel = False
|
|
70
|
+
if msg.get("mostrar"):
|
|
71
|
+
self.visivel = True
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def rotulo(self) -> str:
|
|
75
|
+
return ROTULOS.get(self.estado, self.estado.upper())
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def cor(self) -> str:
|
|
79
|
+
"""ciano = a conversa (ouvir, falar); âmbar = o trabalho (pensar, executar)."""
|
|
80
|
+
return "ambar" if self.estado in ("pensando", "executando", "interrompido") else "ciano"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── o que o computador está fazendo (sem dependência) ────────────────────
|
|
84
|
+
|
|
85
|
+
def _rodar(cmd: List[str], t: float = 3.0) -> str:
|
|
86
|
+
try:
|
|
87
|
+
return subprocess.run(cmd, capture_output=True, text=True, timeout=t).stdout
|
|
88
|
+
except Exception:
|
|
89
|
+
return ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def coletar_sistema() -> Dict[str, Optional[float]]:
|
|
93
|
+
"""cpu, mem e bateria em 0..1 (None = não deu para medir)."""
|
|
94
|
+
cpu = mem = bat = None
|
|
95
|
+
try:
|
|
96
|
+
n = os.cpu_count() or 1
|
|
97
|
+
cpu = min(1.0, os.getloadavg()[0] / n)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
try:
|
|
101
|
+
import psutil # opcional
|
|
102
|
+
cpu = psutil.cpu_percent(interval=None) / 100.0
|
|
103
|
+
mem = psutil.virtual_memory().percent / 100.0
|
|
104
|
+
b = psutil.sensors_battery()
|
|
105
|
+
bat = b.percent / 100.0 if b else None
|
|
106
|
+
return {"cpu": cpu, "mem": mem, "bat": bat}
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
if shutil.which("vm_stat"):
|
|
110
|
+
s = _rodar(["vm_stat"])
|
|
111
|
+
pag = {k: int(v) for k, v in re.findall(r"Pages ([a-z ]+):\s+(\d+)", s)}
|
|
112
|
+
usadas = pag.get("active", 0) + pag.get("wired down", 0) + pag.get("occupied by compressor", 0)
|
|
113
|
+
total = usadas + pag.get("free", 0) + pag.get("inactive", 0) + pag.get("speculative", 0)
|
|
114
|
+
mem = usadas / total if total else None
|
|
115
|
+
if shutil.which("pmset"):
|
|
116
|
+
m = re.search(r"(\d+)%", _rodar(["pmset", "-g", "batt"]))
|
|
117
|
+
bat = int(m.group(1)) / 100.0 if m else None
|
|
118
|
+
return {"cpu": cpu, "mem": mem, "bat": bat}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _osa(script: str, t: float = 2.5) -> str:
|
|
122
|
+
return _rodar(["osascript", "-e", script], t).strip()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def tocando() -> str:
|
|
126
|
+
"""Faixa — artista do Spotify ou do Music (só se o app já estiver aberto).
|
|
127
|
+
"♪ " tocando, "⏸ " pausado. Quem toca ganha de quem só está pausado."""
|
|
128
|
+
if sys.platform != "darwin":
|
|
129
|
+
return ""
|
|
130
|
+
pausado = ""
|
|
131
|
+
for app in ("Spotify", "Music"):
|
|
132
|
+
r = _osa(f'if application "{app}" is running then tell application "{app}" to return '
|
|
133
|
+
'(player state as string) & "|" & name of current track & " — " & artist of current track', 2.0)
|
|
134
|
+
estado, _, faixa = r.partition("|")
|
|
135
|
+
if not faixa:
|
|
136
|
+
continue
|
|
137
|
+
if estado == "playing":
|
|
138
|
+
return faixa
|
|
139
|
+
pausado = pausado or "⏸ " + faixa
|
|
140
|
+
return pausado
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def app_em_foco() -> str:
|
|
144
|
+
if sys.platform != "darwin":
|
|
145
|
+
return ""
|
|
146
|
+
return _osa('tell application "System Events" to name of first application process whose frontmost is true')
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def wifi() -> str:
|
|
150
|
+
if sys.platform != "darwin":
|
|
151
|
+
return ""
|
|
152
|
+
for dev in ("en0", "en1"):
|
|
153
|
+
r = _rodar(["networksetup", "-getairportnetwork", dev], 2.0)
|
|
154
|
+
m = re.search(r"Network:\s*(.+)", r)
|
|
155
|
+
if m:
|
|
156
|
+
return m.group(1).strip()
|
|
157
|
+
return ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def clima(cache: dict) -> str:
|
|
161
|
+
"""wttr.in (grátis, sem chave), no máximo a cada 20 min. Offline = vazio."""
|
|
162
|
+
if time.time() - cache.get("t", 0) < 1200:
|
|
163
|
+
return cache.get("v", "")
|
|
164
|
+
cache["t"] = time.time()
|
|
165
|
+
try:
|
|
166
|
+
import urllib.request
|
|
167
|
+
with urllib.request.urlopen(urllib.request.Request(
|
|
168
|
+
"https://wttr.in/?format=%t+%C&lang=pt", headers={"User-Agent": "curl/8"}), timeout=4) as r:
|
|
169
|
+
v = r.read().decode("utf-8", "replace").strip()
|
|
170
|
+
cache["v"] = v if v and len(v) < 40 and "Unknown" not in v else ""
|
|
171
|
+
except Exception:
|
|
172
|
+
cache["v"] = cache.get("v", "")
|
|
173
|
+
return cache["v"]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def proximo_evento() -> str:
|
|
177
|
+
"""Próximo evento do Calendário. Só com PRIMOCODE_VOZ_CALENDARIO=1: o macOS pede permissão."""
|
|
178
|
+
if sys.platform != "darwin" or os.environ.get("PRIMOCODE_VOZ_CALENDARIO") != "1":
|
|
179
|
+
return ""
|
|
180
|
+
return _osa('tell application "Calendar" to summary of (first event of calendar 1 '
|
|
181
|
+
'whose start date > (current date))', 6)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def disco() -> Optional[float]:
|
|
185
|
+
try:
|
|
186
|
+
u = shutil.disk_usage("/")
|
|
187
|
+
return u.used / u.total
|
|
188
|
+
except Exception:
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def coletar_tudo(cache: dict) -> dict:
|
|
193
|
+
"""Tudo o que o painel mostra do computador. Lento (osascript): rode em thread."""
|
|
194
|
+
sis = coletar_sistema()
|
|
195
|
+
sis["disco"] = disco()
|
|
196
|
+
return {"sistema": sis, "info": {"tocando": tocando(), "app": app_em_foco(), "wifi": wifi(),
|
|
197
|
+
"clima": clima(cache), "evento": proximo_evento()}}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ── o painel no terminal (fora do Mac, ou sem PyObjC) ────────────────────
|
|
201
|
+
|
|
202
|
+
def _barra(v: Optional[float], n: int = 10) -> str:
|
|
203
|
+
if v is None:
|
|
204
|
+
return "·" * n
|
|
205
|
+
cheio = int(round(max(0.0, min(1.0, v)) * n))
|
|
206
|
+
return "█" * cheio + "░" * (n - cheio)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def renderizar_tui(p: Painel, largura: int = 56) -> str:
|
|
210
|
+
"""Um quadro de texto puro: cabe em qualquer terminal."""
|
|
211
|
+
larg = max(40, largura)
|
|
212
|
+
u = larg - 4
|
|
213
|
+
anel = {"ouvindo": "(( ◉ ))", "pensando": "(( ◌ ))", "falando": "((( ◉ )))", "executando": "(( ▸ ))",
|
|
214
|
+
"dormindo": "( · )", "interrompido": "(( ✕ ))"}.get(p.estado, "( ◉ )")
|
|
215
|
+
|
|
216
|
+
def corta(t: str, n: int) -> str:
|
|
217
|
+
t = " ".join(str(t).split())
|
|
218
|
+
return t if len(t) <= n else t[: n - 1] + "…"
|
|
219
|
+
|
|
220
|
+
def linha(t: str) -> str:
|
|
221
|
+
return "│ " + corta(t, u).ljust(u) + " │"
|
|
222
|
+
|
|
223
|
+
agora = time.strftime("%H:%M %d/%m")
|
|
224
|
+
s = p.sistema
|
|
225
|
+
linhas = [
|
|
226
|
+
"┌" + "─" * (larg - 2) + "┐",
|
|
227
|
+
linha(f"{anel:<9} {p.nome.upper()} · {p.rotulo}".ljust(u - len(agora)) + agora),
|
|
228
|
+
linha("você › " + p.transcricao),
|
|
229
|
+
linha(f"{p.nome.lower()[:5]:<5} › " + p.resposta),
|
|
230
|
+
linha(f"cpu {_barra(s.get('cpu'), 8)} mem {_barra(s.get('mem'), 8)} disco {_barra(s.get('disco'), 8)}"),
|
|
231
|
+
linha(f"bat {_barra(s.get('bat'), 8)} wifi {p.info.get('wifi') or '-'} {p.info.get('clima') or ''}"),
|
|
232
|
+
linha("♪ " + (p.info.get("tocando") or "—") + (" ▢ " + p.info["app"] if p.info.get("app") else "")),
|
|
233
|
+
"└" + "─" * (larg - 2) + "┘",
|
|
234
|
+
]
|
|
235
|
+
return "\n".join(linhas)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def tui_laco(entrada, saida=None, largura: int = 56) -> None:
|
|
239
|
+
"""Lê o protocolo e redesenha o quadro no lugar. Para o `/voice painel` fora do Mac."""
|
|
240
|
+
import sys
|
|
241
|
+
saida = saida or sys.stdout
|
|
242
|
+
p = Painel()
|
|
243
|
+
ultimo = 0.0
|
|
244
|
+
cache: dict = {}
|
|
245
|
+
cache_t = [0.0]
|
|
246
|
+
for linha in entrada:
|
|
247
|
+
try:
|
|
248
|
+
import json
|
|
249
|
+
p.aplicar(json.loads(linha))
|
|
250
|
+
except ValueError:
|
|
251
|
+
continue
|
|
252
|
+
if time.time() - ultimo > 0.2:
|
|
253
|
+
if time.time() - cache_t[0] > 5:
|
|
254
|
+
r = coletar_tudo(cache)
|
|
255
|
+
p.sistema, p.info = r["sistema"], {**p.info, **r["info"]}
|
|
256
|
+
cache_t[0] = time.time()
|
|
257
|
+
saida.write("\x1b[H\x1b[J" + renderizar_tui(p, largura) + "\n")
|
|
258
|
+
saida.flush()
|
|
259
|
+
ultimo = time.time()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ── amostras ao vivo para o HUD de tela cheia ────────────────────────────
|
|
263
|
+
|
|
264
|
+
class Amostrador:
|
|
265
|
+
"""CPU por núcleo e tráfego de rede, por diferença entre duas leituras."""
|
|
266
|
+
|
|
267
|
+
def __init__(self) -> None:
|
|
268
|
+
self._cpu = None
|
|
269
|
+
self._rede = None
|
|
270
|
+
self._lib = None
|
|
271
|
+
|
|
272
|
+
def _ticks(self):
|
|
273
|
+
import ctypes
|
|
274
|
+
from ctypes import POINTER, byref, c_int, c_uint
|
|
275
|
+
if self._lib is None:
|
|
276
|
+
self._lib = ctypes.CDLL("/usr/lib/libSystem.B.dylib")
|
|
277
|
+
self._lib.mach_host_self.restype = c_uint
|
|
278
|
+
n, info, cnt = c_uint(), POINTER(c_int)(), c_uint()
|
|
279
|
+
if self._lib.host_processor_info(self._lib.mach_host_self(), 2, byref(n), byref(info), byref(cnt)) != 0:
|
|
280
|
+
return None
|
|
281
|
+
ticks = [tuple(info[i * 4 + k] & 0xFFFFFFFF for k in range(4)) for i in range(n.value)]
|
|
282
|
+
try:
|
|
283
|
+
self._lib.vm_deallocate.argtypes = [c_uint, ctypes.c_void_p, ctypes.c_size_t]
|
|
284
|
+
self._lib.vm_deallocate(c_uint.in_dll(self._lib, "mach_task_self_").value,
|
|
285
|
+
ctypes.cast(info, ctypes.c_void_p), cnt.value * 4)
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
return ticks
|
|
289
|
+
|
|
290
|
+
def cores(self) -> List[float]:
|
|
291
|
+
if sys.platform != "darwin":
|
|
292
|
+
return []
|
|
293
|
+
try:
|
|
294
|
+
atual = self._ticks()
|
|
295
|
+
except Exception:
|
|
296
|
+
return []
|
|
297
|
+
antes, self._cpu = self._cpu, atual
|
|
298
|
+
if not atual or not antes or len(antes) != len(atual):
|
|
299
|
+
return [0.0] * len(atual or [])
|
|
300
|
+
saida = []
|
|
301
|
+
for a, b in zip(antes, atual):
|
|
302
|
+
d = [(y - x) & 0xFFFFFFFF for x, y in zip(a, b)]
|
|
303
|
+
total = sum(d)
|
|
304
|
+
saida.append(0.0 if not total else 1.0 - d[2] / total)
|
|
305
|
+
return saida
|
|
306
|
+
|
|
307
|
+
def rede(self) -> Dict[str, float]:
|
|
308
|
+
"""bytes/s de entrada e saída (soma das interfaces en*)."""
|
|
309
|
+
s = _rodar(["netstat", "-ib"], 2.0)
|
|
310
|
+
linhas = s.splitlines()
|
|
311
|
+
if not linhas:
|
|
312
|
+
return {"up": 0.0, "down": 0.0}
|
|
313
|
+
cab = linhas[0].split()
|
|
314
|
+
try:
|
|
315
|
+
ii, oi = cab.index("Ibytes"), cab.index("Obytes")
|
|
316
|
+
except ValueError:
|
|
317
|
+
return {"up": 0.0, "down": 0.0}
|
|
318
|
+
vistos, ent, sai = set(), 0, 0
|
|
319
|
+
for l in linhas[1:]:
|
|
320
|
+
c = l.split()
|
|
321
|
+
if len(c) > max(ii, oi) and c[0].startswith("en") and c[0] not in vistos and "<Link" in l:
|
|
322
|
+
vistos.add(c[0])
|
|
323
|
+
try:
|
|
324
|
+
ent += int(c[ii])
|
|
325
|
+
sai += int(c[oi])
|
|
326
|
+
except ValueError:
|
|
327
|
+
pass
|
|
328
|
+
agora = time.time()
|
|
329
|
+
antes, self._rede = self._rede, (agora, ent, sai)
|
|
330
|
+
if not antes or agora <= antes[0]:
|
|
331
|
+
return {"up": 0.0, "down": 0.0}
|
|
332
|
+
dt = agora - antes[0]
|
|
333
|
+
return {"down": max(0, ent - antes[1]) / dt, "up": max(0, sai - antes[2]) / dt}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def carregando() -> bool:
|
|
337
|
+
return "AC Power" in _rodar(["pmset", "-g", "batt"], 2.0) if sys.platform == "darwin" else False
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def capa_da_faixa() -> str:
|
|
341
|
+
"""URL da capa no Spotify (o Music não expõe URL)."""
|
|
342
|
+
if sys.platform != "darwin":
|
|
343
|
+
return ""
|
|
344
|
+
return _osa('if application "Spotify" is running then tell application "Spotify" to return artwork url of current track', 2.0)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ── extras do HUD de tela cheia: apps do Dock, rede, tempo, mídia ────────
|
|
348
|
+
|
|
349
|
+
def apps_do_dock() -> List[str]:
|
|
350
|
+
"""Os apps fixos no Dock da pessoa (nomes), via defaults + plutil."""
|
|
351
|
+
if sys.platform != "darwin":
|
|
352
|
+
return []
|
|
353
|
+
import plistlib
|
|
354
|
+
r = subprocess.run(["defaults", "export", "com.apple.dock", "-"], capture_output=True, timeout=3)
|
|
355
|
+
try:
|
|
356
|
+
j = plistlib.loads(r.stdout)
|
|
357
|
+
except Exception:
|
|
358
|
+
return []
|
|
359
|
+
nomes = []
|
|
360
|
+
for it in j.get("persistent-apps", []):
|
|
361
|
+
d = it.get("tile-data", {})
|
|
362
|
+
n = d.get("file-label")
|
|
363
|
+
if n and n not in ("Launchpad", "Mission Control", "Apps"):
|
|
364
|
+
nomes.append(n)
|
|
365
|
+
return nomes
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def ip_local() -> str:
|
|
369
|
+
try:
|
|
370
|
+
import socket
|
|
371
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
372
|
+
s.connect(("10.255.255.255", 1))
|
|
373
|
+
ip = s.getsockname()[0]
|
|
374
|
+
s.close()
|
|
375
|
+
return ip
|
|
376
|
+
except Exception:
|
|
377
|
+
return ""
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def uptime_s() -> int:
|
|
381
|
+
m = re.search(r"sec = (\d+)", _rodar(["sysctl", "-n", "kern.boottime"], 2.0))
|
|
382
|
+
return int(time.time() - int(m.group(1))) if m else 0
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _json_url(url: str, t: float = 5.0):
|
|
386
|
+
import json as _j
|
|
387
|
+
import urllib.request
|
|
388
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "primocode"}), timeout=t) as r:
|
|
389
|
+
return _j.loads(r.read().decode("utf-8", "replace"))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def local_e_clima3(cache: dict) -> dict:
|
|
393
|
+
"""IP público, cidade e previsão de 3 dias. ipapi.co + open-meteo: grátis, sem chave. Cache de 30 min."""
|
|
394
|
+
if time.time() - cache.get("t3", 0) < 1800 and "v3" in cache:
|
|
395
|
+
return cache["v3"]
|
|
396
|
+
cache["t3"] = time.time()
|
|
397
|
+
v = cache.get("v3", {})
|
|
398
|
+
try:
|
|
399
|
+
g = _json_url("https://ipapi.co/json/")
|
|
400
|
+
lat, lon = float(g["latitude"]), float(g["longitude"])
|
|
401
|
+
v = {"ip_publico": g.get("ip", ""), "cidade": g.get("city", ""), "lat": lat, "lon": lon}
|
|
402
|
+
f = _json_url(f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}"
|
|
403
|
+
"&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=3")
|
|
404
|
+
d = f["daily"]
|
|
405
|
+
v["previsao"] = [{"dia": d["time"][i], "max": round(d["temperature_2m_max"][i]), "min": round(d["temperature_2m_min"][i]),
|
|
406
|
+
"cod": d["weather_code"][i]} for i in range(len(d["time"]))]
|
|
407
|
+
except Exception:
|
|
408
|
+
pass
|
|
409
|
+
cache["v3"] = v
|
|
410
|
+
return v
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def midia() -> dict:
|
|
414
|
+
"""Spotify (ou Music): estado, posição e duração em segundos."""
|
|
415
|
+
if sys.platform != "darwin":
|
|
416
|
+
return {}
|
|
417
|
+
for app in ("Spotify", "Music"):
|
|
418
|
+
div = "1000" if app == "Spotify" else "1"
|
|
419
|
+
r = _osa(f'if application "{app}" is running then tell application "{app}" to return (player state as string) & "|" '
|
|
420
|
+
f'& (player position as string) & "|" & ((duration of current track) as string)', 2.0)
|
|
421
|
+
p = r.split("|")
|
|
422
|
+
if len(p) == 3:
|
|
423
|
+
try:
|
|
424
|
+
return {"app": app, "estado": p[0], "pos": float(p[1].replace(",", ".")),
|
|
425
|
+
"dur": float(p[2].replace(",", ".")) / float(div)}
|
|
426
|
+
except ValueError:
|
|
427
|
+
continue
|
|
428
|
+
return {}
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def controle_midia(acao: str) -> None:
|
|
432
|
+
cmd = {"anterior": "previous track", "tocar": "playpause", "proxima": "next track"}.get(acao)
|
|
433
|
+
if not cmd or sys.platform != "darwin":
|
|
434
|
+
return
|
|
435
|
+
for app in ("Spotify", "Music"):
|
|
436
|
+
if _osa(f'application "{app}" is running') == "true":
|
|
437
|
+
_osa(f'tell application "{app}" to {cmd}')
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def executar_acao(m: dict) -> None:
|
|
442
|
+
"""Ações dos botões do HUD. Tudo real; sem Mac, não faz nada."""
|
|
443
|
+
a, arg = m.get("acao"), str(m.get("arg") or "")
|
|
444
|
+
if sys.platform != "darwin":
|
|
445
|
+
return
|
|
446
|
+
if a == "app" and arg:
|
|
447
|
+
subprocess.Popen(["open", "-a", arg])
|
|
448
|
+
elif a == "midia":
|
|
449
|
+
controle_midia(arg)
|
|
450
|
+
elif a == "volume":
|
|
451
|
+
d = 10 if arg == "mais" else -10
|
|
452
|
+
_osa(f"set volume output volume ((output volume of (get volume settings)) + ({d}))")
|
|
453
|
+
elif a == "travar":
|
|
454
|
+
subprocess.Popen(["pmset", "displaysleepnow"])
|
|
455
|
+
elif a == "foco":
|
|
456
|
+
subprocess.Popen(["open", "x-apple.systempreferences:com.apple.Focus-Settings.extension"])
|
|
457
|
+
elif a == "print":
|
|
458
|
+
pasta = os.path.expanduser("~/Desktop")
|
|
459
|
+
subprocess.Popen(["screencapture", "-x", os.path.join(pasta, "primo-" + time.strftime("%Y%m%d-%H%M%S") + ".png")])
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""painel_tui.py — o painel do Primo no terminal, para quem não está no Mac (ou sem PyObjC)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from primo_voz import painel as pn
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> int:
|
|
11
|
+
p = pn.Painel()
|
|
12
|
+
cache: dict = {}
|
|
13
|
+
am = pn.Amostrador()
|
|
14
|
+
while True:
|
|
15
|
+
r = pn.coletar_tudo(cache)
|
|
16
|
+
p.sistema, p.info = r["sistema"], {**p.info, **r["info"]}
|
|
17
|
+
sys.stdout.write("\x1b[H\x1b[J" + pn.renderizar_tui(p) + "\n")
|
|
18
|
+
sys.stdout.flush()
|
|
19
|
+
time.sleep(2)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
try:
|
|
24
|
+
sys.exit(main())
|
|
25
|
+
except KeyboardInterrupt:
|
|
26
|
+
sys.exit(0)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""sons.py — os efeitos sonoros de interface do Primo, sintetizados (sem arquivo de terceiros).
|
|
2
|
+
|
|
3
|
+
Curtos (60-300 ms), de volume baixo, notas de uma escala pentatônica com
|
|
4
|
+
decaimento suave — nada de bipe de alarme. São gerados uma vez em
|
|
5
|
+
~/.primocode/voz-sons/*.wav e tocados com o tocador do sistema. Regra: nunca em
|
|
6
|
+
cima da fala (quem chama passa `falando=True`) e desligável (voz.json "sons":
|
|
7
|
+
false, ou PRIMOCODE_SEM_SONS=1).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import struct
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import wave
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Dict, List, Tuple
|
|
20
|
+
|
|
21
|
+
TAXA = 22050
|
|
22
|
+
VOLUME = 0.22 # 0..1 do arquivo; o tocador ainda baixa mais
|
|
23
|
+
|
|
24
|
+
# nome → lista de (frequência Hz, início s, duração s, ganho)
|
|
25
|
+
RECEITAS: Dict[str, List[Tuple[float, float, float, float]]] = {
|
|
26
|
+
"boot": [(261.6, 0.00, 0.30, 0.5), (392.0, 0.18, 0.30, 0.55), (523.3, 0.36, 0.30, 0.6),
|
|
27
|
+
(784.0, 0.54, 0.30, 0.7), (1046.5, 0.72, 0.60, 0.9)],
|
|
28
|
+
"acordou": [(523.3, 0.00, 0.12, 0.8), (784.0, 0.09, 0.22, 1.0)],
|
|
29
|
+
"ouvindo": [(880.0, 0.00, 0.07, 0.6)],
|
|
30
|
+
"entendeu": [(659.3, 0.00, 0.07, 0.7), (880.0, 0.06, 0.12, 0.8)],
|
|
31
|
+
"pensando": [(392.0, 0.00, 0.18, 0.35)],
|
|
32
|
+
"pronto": [(784.0, 0.00, 0.10, 0.7), (1046.5, 0.08, 0.26, 0.9)],
|
|
33
|
+
"erro": [(311.1, 0.00, 0.14, 0.9), (233.1, 0.12, 0.26, 0.9)],
|
|
34
|
+
"painel_abre": [(587.3, 0.00, 0.06, 0.6), (880.0, 0.05, 0.10, 0.7)],
|
|
35
|
+
"painel_fecha": [(880.0, 0.00, 0.06, 0.6), (587.3, 0.05, 0.10, 0.7)],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sintetizar(nome: str) -> List[int]:
|
|
40
|
+
notas = RECEITAS[nome]
|
|
41
|
+
total = max(i + d for _, i, d, _ in notas) + 0.05
|
|
42
|
+
n = int(total * TAXA)
|
|
43
|
+
amostras = [0.0] * n
|
|
44
|
+
for f, ini, dur, g in notas:
|
|
45
|
+
a = int(ini * TAXA)
|
|
46
|
+
for k in range(int(dur * TAXA)):
|
|
47
|
+
if a + k >= n:
|
|
48
|
+
break
|
|
49
|
+
t = k / TAXA
|
|
50
|
+
ataque = min(1.0, t / 0.006)
|
|
51
|
+
queda = math.exp(-t * (5.0 / dur))
|
|
52
|
+
v = math.sin(math.tau * f * t) + 0.25 * math.sin(math.tau * 2 * f * t)
|
|
53
|
+
amostras[a + k] += v * ataque * queda * g
|
|
54
|
+
pico = max(1e-9, max(abs(x) for x in amostras))
|
|
55
|
+
return [int(32767 * VOLUME * x / max(pico, 1.0)) for x in amostras]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def pasta() -> Path:
|
|
59
|
+
return Path(os.environ.get("PRIMOCODE_SONS_DIR") or (Path.home() / ".primocode" / "voz-sons"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def arquivo(nome: str) -> Path:
|
|
63
|
+
p = pasta() / f"{nome}.wav"
|
|
64
|
+
if not p.exists():
|
|
65
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
with wave.open(str(p), "wb") as w:
|
|
67
|
+
w.setnchannels(1)
|
|
68
|
+
w.setsampwidth(2)
|
|
69
|
+
w.setframerate(TAXA)
|
|
70
|
+
w.writeframes(b"".join(struct.pack("<h", x) for x in sintetizar(nome)))
|
|
71
|
+
return p
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Sons:
|
|
75
|
+
def __init__(self, ligado: bool = True) -> None:
|
|
76
|
+
self.ligado = ligado and os.environ.get("PRIMOCODE_SEM_SONS") != "1"
|
|
77
|
+
self.cmd = None
|
|
78
|
+
if self.ligado:
|
|
79
|
+
if sys.platform == "darwin":
|
|
80
|
+
self.cmd = ["afplay", "-v", "0.5"]
|
|
81
|
+
else:
|
|
82
|
+
for c in (["paplay"], ["aplay", "-q"], ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet"]):
|
|
83
|
+
if shutil.which(c[0]):
|
|
84
|
+
self.cmd = c
|
|
85
|
+
break
|
|
86
|
+
self.ligado = bool(self.cmd)
|
|
87
|
+
|
|
88
|
+
def tocar(self, nome: str, falando: bool = False) -> bool:
|
|
89
|
+
"""Toca e volta na hora. Em cima da fala (falando=True) não toca."""
|
|
90
|
+
if not self.ligado or falando or nome not in RECEITAS:
|
|
91
|
+
return False
|
|
92
|
+
try:
|
|
93
|
+
subprocess.Popen(self.cmd + [str(arquivo(nome))], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
94
|
+
return True
|
|
95
|
+
except Exception:
|
|
96
|
+
return False
|