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.
@@ -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
+ "&current=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