primocode 10.14.1 → 10.15.0
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 +46 -3
- package/lib/act.js +1 -0
- package/lib/preparar.js +2 -0
- package/lib/voz-ponte.js +15 -0
- package/lib/voz.js +75 -5
- package/package.json +3 -2
- package/voz/primo_voz/__main__.py +307 -54
- package/voz/primo_voz/conversa.py +236 -0
- package/voz/primo_voz/escuta.py +32 -2
- package/voz/primo_voz/hud.html +550 -0
- package/voz/primo_voz/hud.py +224 -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 +337 -0
- package/voz/primo_voz/vendor/RobotExpressive.glb +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,337 @@
|
|
|
1
|
+
"""tela.py — os dados REAIS da tela do Primo (calendário, clima, música, máquina) e as regras pequenas dela.
|
|
2
|
+
|
|
3
|
+
Nada aqui desenha. hud.py chama estas funções numa thread e empurra o resultado para hud.html;
|
|
4
|
+
a lógica que não depende de tela (texto de "em X min", troca de robô, quem fala nas barras) se testa sem microfone.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
from typing import Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from primo_voz import identidade
|
|
19
|
+
from primo_voz import painel as pn
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── regras puras ─────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
def em_x_min(inicio: float, agora: float) -> str:
|
|
25
|
+
"""'em 25 min', 'em 1 h 10 min', 'agora' (começou há pouco), 'amanhã' passa da meia-noite."""
|
|
26
|
+
d = int((inicio - agora) // 60)
|
|
27
|
+
if d < -5:
|
|
28
|
+
return "já começou"
|
|
29
|
+
if d <= 0:
|
|
30
|
+
return "agora"
|
|
31
|
+
if d < 60:
|
|
32
|
+
return f"em {d} min"
|
|
33
|
+
h, m = divmod(d, 60)
|
|
34
|
+
if h >= 24:
|
|
35
|
+
return "amanhã"
|
|
36
|
+
return f"em {h} h" + (f" {m} min" if m else "")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def barras(estado: str, nivel: float) -> Dict[str, object]:
|
|
40
|
+
"""Quem fala e quão alto: a PESSOA (ciano) em 'ouvindo' e em espera (o mic escuta); o PRIMO (verde) só em 'falando'. O resto é silêncio."""
|
|
41
|
+
n = max(0.0, min(1.0, float(nivel or 0)))
|
|
42
|
+
if estado in ("ouvindo", "dormindo"):
|
|
43
|
+
return {"quem": "pessoa", "nivel": n}
|
|
44
|
+
if estado == "falando":
|
|
45
|
+
return {"quem": "primo", "nivel": n}
|
|
46
|
+
return {"quem": None, "nivel": 0.0}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def nivel_do_mic(rms: float) -> float:
|
|
50
|
+
return max(0.0, min(1.0, rms / 4000.0))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def envelope(arquivo, janela: float = 0.05) -> List[float]:
|
|
54
|
+
"""Volume real da fala do Primo (0..1 a cada 50 ms): converte o mp3 em wav com afconvert e mede. Sem Mac = []."""
|
|
55
|
+
if sys.platform != "darwin" or not shutil.which("afconvert"):
|
|
56
|
+
return []
|
|
57
|
+
import array
|
|
58
|
+
import wave
|
|
59
|
+
tmp = tempfile.mktemp(suffix=".wav")
|
|
60
|
+
try:
|
|
61
|
+
subprocess.run(["afconvert", "-f", "WAVE", "-d", "LEI16@16000", "-c", "1", str(arquivo), tmp],
|
|
62
|
+
capture_output=True, timeout=10, check=True)
|
|
63
|
+
with wave.open(tmp) as w:
|
|
64
|
+
dados = array.array("h", w.readframes(w.getnframes()))
|
|
65
|
+
taxa = w.getframerate()
|
|
66
|
+
passo = max(1, int(taxa * janela))
|
|
67
|
+
env = []
|
|
68
|
+
for i in range(0, len(dados), passo):
|
|
69
|
+
b = dados[i:i + passo]
|
|
70
|
+
env.append((sum(x * x for x in b) / len(b)) ** 0.5 if b else 0.0)
|
|
71
|
+
pico = max(env) if env else 0
|
|
72
|
+
return [round(min(1.0, e / pico * 1.1), 3) for e in env] if pico else []
|
|
73
|
+
except Exception:
|
|
74
|
+
return []
|
|
75
|
+
finally:
|
|
76
|
+
try:
|
|
77
|
+
os.unlink(tmp)
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def hoje_lista(eventos: List[dict], agora: float) -> List[dict]:
|
|
83
|
+
"""Os eventos de HOJE que ainda importam (não acabaram), o primeiro marcado como próximo, com 'em X min'."""
|
|
84
|
+
t = time.localtime(agora)
|
|
85
|
+
out = []
|
|
86
|
+
for e in eventos:
|
|
87
|
+
if time.localtime(e["inicio"]).tm_mday != t.tm_mday or e["fim"] <= agora and not e.get("dia_todo"):
|
|
88
|
+
continue
|
|
89
|
+
item = {"titulo": e["titulo"], "hora": "dia todo" if e.get("dia_todo") else time.strftime("%H:%M", time.localtime(e["inicio"])),
|
|
90
|
+
"em": "" if e.get("dia_todo") else em_x_min(e["inicio"], agora), "prox": False}
|
|
91
|
+
out.append(item)
|
|
92
|
+
for item in out:
|
|
93
|
+
if item["hora"] != "dia todo":
|
|
94
|
+
item["prox"] = True
|
|
95
|
+
break
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── calendário (EventKit; pede permissão uma vez) ────────────────────────
|
|
100
|
+
|
|
101
|
+
_ek: dict = {}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _store():
|
|
105
|
+
if "s" not in _ek:
|
|
106
|
+
from EventKit import EKEventStore
|
|
107
|
+
_ek["s"] = EKEventStore.alloc().init()
|
|
108
|
+
return _ek["s"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def calendario_permissao() -> str:
|
|
112
|
+
"""ok | pendente | negada | sem (sem EventKit/sem Mac)."""
|
|
113
|
+
if sys.platform != "darwin":
|
|
114
|
+
return "sem"
|
|
115
|
+
try:
|
|
116
|
+
from EventKit import EKEventStore
|
|
117
|
+
except ImportError:
|
|
118
|
+
return "sem"
|
|
119
|
+
st = EKEventStore.authorizationStatusForEntityType_(0)
|
|
120
|
+
return {3: "ok", 0: "pendente", 4: "negada", 1: "negada", 2: "negada"}.get(int(st), "negada")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def pedir_calendario() -> None:
|
|
124
|
+
"""Dispara o pedido do macOS (uma vez). Não bloqueia."""
|
|
125
|
+
try:
|
|
126
|
+
s = _store()
|
|
127
|
+
if hasattr(s, "requestFullAccessToEventsWithCompletion_"):
|
|
128
|
+
s.requestFullAccessToEventsWithCompletion_(lambda ok, err: None)
|
|
129
|
+
else:
|
|
130
|
+
s.requestAccessToEntityType_completion_(0, lambda ok, err: None)
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
_OSA_CAL = '''set d1 to current date
|
|
136
|
+
set day of d1 to 1
|
|
137
|
+
set time of d1 to 0
|
|
138
|
+
set d2 to d1 + 32 * days
|
|
139
|
+
set day of d2 to 1
|
|
140
|
+
set agora to current date
|
|
141
|
+
set out to ""
|
|
142
|
+
tell application "Calendar"
|
|
143
|
+
repeat with c in calendars
|
|
144
|
+
repeat with e in (every event of c whose start date >= d1 and start date < d2)
|
|
145
|
+
set out to out & (summary of e) & "|||" & ((start date of e) - agora) & "|||" & ((end date of e) - agora) & "|||" & (allday event of e) & linefeed
|
|
146
|
+
end repeat
|
|
147
|
+
end repeat
|
|
148
|
+
end tell
|
|
149
|
+
return out'''
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _eventos_osascript(agora: float) -> dict:
|
|
153
|
+
"""Plano B quando o EventKit não consegue pedir a permissão (Python sem pacote de app): o app Calendário via osascript."""
|
|
154
|
+
r = subprocess.run(["osascript", "-e", _OSA_CAL], capture_output=True, text=True, timeout=25)
|
|
155
|
+
if r.returncode != 0:
|
|
156
|
+
return {"permissao": "negada" if "-1743" in r.stderr else "pendente", "eventos": []}
|
|
157
|
+
out = []
|
|
158
|
+
for l in r.stdout.splitlines():
|
|
159
|
+
p = l.split("|||")
|
|
160
|
+
if len(p) == 4:
|
|
161
|
+
try:
|
|
162
|
+
a, b = agora + float(p[1]), agora + float(p[2])
|
|
163
|
+
except ValueError:
|
|
164
|
+
continue
|
|
165
|
+
out.append({"titulo": p[0] or "(sem título)", "inicio": a, "fim": b, "dia_todo": p[3].strip() == "true",
|
|
166
|
+
"dia": time.localtime(a).tm_mday})
|
|
167
|
+
out.sort(key=lambda e: e["inicio"])
|
|
168
|
+
return {"permissao": "ok", "eventos": out}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def eventos_do_mes(agora: Optional[float] = None) -> dict:
|
|
172
|
+
"""{'permissao','eventos':[{titulo,inicio,fim,dia_todo,dia}]} do mês inteiro (hoje e dias com ponto)."""
|
|
173
|
+
perm = calendario_permissao()
|
|
174
|
+
if perm in ("pendente", "negada") and sys.platform == "darwin":
|
|
175
|
+
if perm == "pendente":
|
|
176
|
+
pedir_calendario()
|
|
177
|
+
try:
|
|
178
|
+
return _eventos_osascript(agora or time.time())
|
|
179
|
+
except Exception:
|
|
180
|
+
return {"permissao": perm, "eventos": []}
|
|
181
|
+
if perm != "ok":
|
|
182
|
+
return {"permissao": perm, "eventos": []}
|
|
183
|
+
from Foundation import NSDate
|
|
184
|
+
agora = agora or time.time()
|
|
185
|
+
t = time.localtime(agora)
|
|
186
|
+
ini = time.mktime((t.tm_year, t.tm_mon, 1, 0, 0, 0, 0, 0, -1))
|
|
187
|
+
fim = time.mktime((t.tm_year + (t.tm_mon == 12), t.tm_mon % 12 + 1, 1, 0, 0, 0, 0, 0, -1))
|
|
188
|
+
s = _store()
|
|
189
|
+
pred = s.predicateForEventsWithStartDate_endDate_calendars_(NSDate.dateWithTimeIntervalSince1970_(ini),
|
|
190
|
+
NSDate.dateWithTimeIntervalSince1970_(fim), None)
|
|
191
|
+
out = []
|
|
192
|
+
for e in sorted(s.eventsMatchingPredicate_(pred) or [], key=lambda e: e.startDate().timeIntervalSince1970()):
|
|
193
|
+
a, b = e.startDate().timeIntervalSince1970(), e.endDate().timeIntervalSince1970()
|
|
194
|
+
out.append({"titulo": str(e.title() or "(sem título)"), "inicio": a, "fim": b, "dia_todo": bool(e.isAllDay()),
|
|
195
|
+
"dia": time.localtime(a).tm_mday})
|
|
196
|
+
return {"permissao": perm, "eventos": out}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ── clima (open-meteo, cidade por IP, sem chave) ─────────────────────────
|
|
200
|
+
|
|
201
|
+
_COD = [((0,), "Céu limpo", "☀︎"), ((1, 2), "Poucas nuvens", "⛅︎"), ((3,), "Nublado", "☁︎"), ((45, 48), "Neblina", "≋"),
|
|
202
|
+
((51, 53, 55, 56, 57), "Garoa", "☂︎"), ((61, 63, 65, 66, 67, 80, 81, 82), "Chuva", "☂︎"),
|
|
203
|
+
((71, 73, 75, 77, 85, 86), "Neve", "❄︎"), ((95, 96, 99), "Tempestade", "⚡︎")]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def condicao(cod: int):
|
|
207
|
+
for cods, txt, ico in _COD:
|
|
208
|
+
if cod in cods:
|
|
209
|
+
return txt, ico
|
|
210
|
+
return "—", "·"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def clima_widget(cache: dict) -> dict:
|
|
214
|
+
"""cidade, temperatura agora, condição, máx/mín e os próximos 4 dias. Cache de 20 min; offline = o último."""
|
|
215
|
+
if time.time() - cache.get("tw", 0) < 1200 and "w" in cache:
|
|
216
|
+
return cache["w"]
|
|
217
|
+
cache["tw"] = time.time()
|
|
218
|
+
v = cache.get("w", {})
|
|
219
|
+
try:
|
|
220
|
+
g = pn._json_url("https://ipapi.co/json/")
|
|
221
|
+
lat, lon = float(g["latitude"]), float(g["longitude"])
|
|
222
|
+
f = pn._json_url(f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}"
|
|
223
|
+
"¤t=temperature_2m,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min"
|
|
224
|
+
"&timezone=auto&forecast_days=5")
|
|
225
|
+
d, c = f["daily"], f["current"]
|
|
226
|
+
txt, ico = condicao(int(c["weather_code"]))
|
|
227
|
+
v = {"cidade": g.get("city", ""), "temp": round(c["temperature_2m"]), "cond": txt, "icone": ico,
|
|
228
|
+
"max": round(d["temperature_2m_max"][0]), "min": round(d["temperature_2m_min"][0]),
|
|
229
|
+
"dias": [{"data": d["time"][i], "max": round(d["temperature_2m_max"][i]),
|
|
230
|
+
"icone": condicao(int(d["weather_code"][i]))[1]} for i in range(1, len(d["time"]))]}
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
cache["w"] = v
|
|
234
|
+
return v
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ── música (Spotify ou Music) ────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
_capa: dict = {}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _capa_music() -> str:
|
|
243
|
+
"""O Music não dá URL: grava a arte da faixa num arquivo temporário e devolve como data URI."""
|
|
244
|
+
tmp = os.path.join(tempfile.gettempdir(), "primo-capa.dat")
|
|
245
|
+
r = pn._osa('tell application "Music" to tell artwork 1 of current track to set d to raw data\n'
|
|
246
|
+
f'set f to open for access POSIX file "{tmp}" with write permission\nset eof f to 0\nwrite d to f\nclose access f', 4)
|
|
247
|
+
try:
|
|
248
|
+
b = open(tmp, "rb").read()
|
|
249
|
+
os.unlink(tmp)
|
|
250
|
+
except OSError:
|
|
251
|
+
return ""
|
|
252
|
+
if not b:
|
|
253
|
+
return ""
|
|
254
|
+
tipo = "image/png" if b[:4] == b"\x89PNG" else "image/jpeg"
|
|
255
|
+
return f"data:{tipo};base64," + base64.b64encode(b).decode()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def musica() -> dict:
|
|
259
|
+
"""{'app','estado','faixa','artista','pos','dur','capa'}; {} se nada está aberto. A capa só é buscada quando a faixa muda."""
|
|
260
|
+
if sys.platform != "darwin":
|
|
261
|
+
return {}
|
|
262
|
+
for app in ("Spotify", "Music"):
|
|
263
|
+
div = 1000.0 if app == "Spotify" else 1.0
|
|
264
|
+
art = ' & "|" & (artwork url of current track)' if app == "Spotify" else ""
|
|
265
|
+
r = pn._osa(f'if application "{app}" is running then tell application "{app}" to return (player state as string) & "|" & '
|
|
266
|
+
f'(player position as string) & "|" & (duration of current track as string) & "|" & name of current track & "|" '
|
|
267
|
+
f'& artist of current track{art}', 2.5)
|
|
268
|
+
p = r.split("|")
|
|
269
|
+
if len(p) < 5:
|
|
270
|
+
continue
|
|
271
|
+
try:
|
|
272
|
+
pos, dur = float(p[1].replace(",", ".")), float(p[2].replace(",", ".")) / div
|
|
273
|
+
except ValueError:
|
|
274
|
+
continue
|
|
275
|
+
chave = (app, p[3], p[4])
|
|
276
|
+
if _capa.get("k") != chave:
|
|
277
|
+
_capa["k"] = chave
|
|
278
|
+
_capa["v"] = p[5] if app == "Spotify" and len(p) > 5 else (_capa_music() if app == "Music" else "")
|
|
279
|
+
return {"app": app, "estado": p[0], "faixa": p[3], "artista": p[4], "pos": pos, "dur": dur, "capa": _capa.get("v", "")}
|
|
280
|
+
return {}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def controle(acao: str) -> None:
|
|
284
|
+
pn.controle_midia(acao)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ── a máquina ────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
def memoria() -> dict:
|
|
290
|
+
"""{'usada_gb','total_gb','pct'} (usada = ativa + fixa + comprimida, como o painel antigo)."""
|
|
291
|
+
try:
|
|
292
|
+
total = int(pn._rodar(["sysctl", "-n", "hw.memsize"], 2.0).strip())
|
|
293
|
+
s = pn._rodar(["vm_stat"], 2.0)
|
|
294
|
+
tam = int(re.search(r"page size of (\d+)", s).group(1))
|
|
295
|
+
pag = {k: int(v) for k, v in re.findall(r"Pages ([a-z ]+):\s+(\d+)", s)}
|
|
296
|
+
usada = (pag.get("active", 0) + pag.get("wired down", 0) + pag.get("occupied by compressor", 0)) * tam
|
|
297
|
+
return {"usada_gb": round(usada / 1e9, 1), "total_gb": round(total / 1e9), "pct": round(100 * usada / total)}
|
|
298
|
+
except Exception:
|
|
299
|
+
return {}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def disco() -> dict:
|
|
303
|
+
try:
|
|
304
|
+
u = shutil.disk_usage("/")
|
|
305
|
+
return {"pct": round(100 * u.used / u.total), "livre_gb": round(u.free / 1e9)}
|
|
306
|
+
except Exception:
|
|
307
|
+
return {}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def bateria() -> dict:
|
|
311
|
+
s = pn._rodar(["pmset", "-g", "batt"], 2.0)
|
|
312
|
+
m = re.search(r"(\d+)%", s)
|
|
313
|
+
if not m:
|
|
314
|
+
return {}
|
|
315
|
+
return {"pct": int(m.group(1)), "tomada": "AC Power" in s}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def maquina(am: "pn.Amostrador") -> dict:
|
|
319
|
+
cores = [round(x, 3) for x in am.cores()]
|
|
320
|
+
r = {"cpu": round(100 * sum(cores) / len(cores)) if cores else 0, "nucleos": len(cores) or (os.cpu_count() or 0),
|
|
321
|
+
"mem": memoria(), "disco": disco(), "bat": bateria()}
|
|
322
|
+
r.update({k: round(v, 1) for k, v in am.rede().items()})
|
|
323
|
+
return r
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def acao(m: dict) -> bool:
|
|
327
|
+
"""Ações da tela que só ela entende. True = tratada."""
|
|
328
|
+
a, arg = m.get("acao"), str(m.get("arg") or "")
|
|
329
|
+
if a == "calendario":
|
|
330
|
+
try:
|
|
331
|
+
r = _eventos_osascript(time.time()) # dispara o pedido do macOS (Automação > Calendário)
|
|
332
|
+
except Exception:
|
|
333
|
+
r = {"permissao": "pendente"}
|
|
334
|
+
if r["permissao"] != "ok":
|
|
335
|
+
subprocess.Popen(["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation"])
|
|
336
|
+
return True
|
|
337
|
+
return False
|
|
Binary file
|