itsMarco-G commited on
Commit
3e090ab
·
1 Parent(s): 858f68f

Initial commit

Browse files
.DS_Store ADDED
Binary file (6.15 kB). View file
 
.hfignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ .DS_Store
2
+ reachy_mini_demos/
3
+ utils/
4
+ yolo26l.pt
README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Reachy Phone Home
3
+ emoji: 📱
4
+ colorFrom: yellow
5
+ colorTo: blue
6
+ sdk: static
7
+ pinned: false
8
+ short_description: Phone focus companion for Reachy Mini
9
+ tags:
10
+ - reachy_mini
11
+ - reachy_mini_python_app
12
+ ---
13
+
14
+ # reachy_phone_home
15
+
16
+ Phone focus companion for Reachy Mini. Reachy watches for your phone, encourages focus, and responds with movements.
17
+
18
+ ## How it works
19
+ - Uses Reachy Mini camera + YOLO phone/person detection.
20
+ - Tracks phone use and triggers movements after configured heartbeats.
21
+ - Includes a manual simulation app for development.
22
+
23
+ ## Run locally
24
+ ```bash
25
+ python reachy_phone_home_app.py --weights yolo26l
26
+ ```
27
+ The YOLO26 weights are downloaded automatically on first run.
28
+
29
+ ## Configure
30
+ Key flags (examples):
31
+ ```bash
32
+ python reachy_phone_home_app.py --weights yolo26l --conf 0.15 --process-every 1
33
+ ```
34
+
35
+ ## Publishing
36
+ Use the Reachy Mini App Assistant to check and publish this app.
index.html ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Reachy Phone Home</title>
7
+ <link rel="stylesheet" href="style.css" />
8
+ </head>
9
+ <body>
10
+ <main class="page">
11
+ <header class="hero">
12
+ <div class="badge">Reachy Mini App</div>
13
+ <h1>Reachy Phone Home</h1>
14
+ <p>
15
+ A focus companion for Reachy Mini. Place your phone in front of Reachy and keep
16
+ it there while you work. Reachy watches for the phone, reacts to use, and
17
+ nudges you back to focus.
18
+ </p>
19
+ <div class="cta">
20
+ <span>Run locally:</span>
21
+ <code>python reachy_phone_home_app.py --weights yolo26l</code>
22
+ </div>
23
+ </header>
24
+
25
+ <section class="grid">
26
+ <div class="card">
27
+ <h2>Phone Watch</h2>
28
+ <p>Detects phone and person, then tracks phone use with stable timing.</p>
29
+ </div>
30
+ <div class="card">
31
+ <h2>Reachy Motions</h2>
32
+ <p>Triggers “good job” movements after steady focus and “bad” movements after use.</p>
33
+ </div>
34
+ <div class="card">
35
+ <h2>Customizable</h2>
36
+ <p>Tune thresholds and timing via CLI flags to fit your space and camera setup.</p>
37
+ </div>
38
+ </section>
39
+ </main>
40
+ </body>
41
+ </html>
pyproject.toml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "reachy_phone_home"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "reachy-mini"
9
+ ]
10
+ keywords = ["reachy-mini-app"]
11
+
12
+ [project.entry-points."reachy_mini_apps"]
13
+ reachy_phone_home = "reachy_phone_home.main:ReachyPhoneHome"
reachy_phone_home.egg-info/PKG-INFO ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: reachy_phone_home
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Keywords: reachy-mini-app
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: reachy-mini
9
+
10
+ ---
11
+ title: Reachy Phone Home
12
+ emoji: 📱
13
+ colorFrom: yellow
14
+ colorTo: blue
15
+ sdk: static
16
+ pinned: false
17
+ short_description: Phone focus companion for Reachy Mini
18
+ tags:
19
+ - reachy_mini
20
+ - reachy_mini_python_app
21
+ ---
22
+
23
+ # reachy_phone_home
24
+
25
+ Phone focus companion for Reachy Mini. Reachy watches for your phone, encourages focus, and responds with movements.
26
+
27
+ ## How it works
28
+ - Uses Reachy Mini camera + YOLO phone/person detection.
29
+ - Tracks phone use and triggers movements after configured heartbeats.
30
+ - Includes a manual simulation app for development.
31
+
32
+ ## Run locally
33
+ ```bash
34
+ python reachy_phone_home_app.py --weights yolo26l
35
+ ```
36
+ The YOLO26 weights are downloaded automatically on first run.
37
+
38
+ ## Configure
39
+ Key flags (examples):
40
+ ```bash
41
+ python reachy_phone_home_app.py --weights yolo26l --conf 0.15 --process-every 1
42
+ ```
43
+
44
+ ## Publishing
45
+ Use the Reachy Mini App Assistant to check and publish this app.
reachy_phone_home.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ setup.cfg
4
+ setup.py
5
+ reachy_phone_home/__init__.py
6
+ reachy_phone_home/main.py
7
+ reachy_phone_home/movements.py
8
+ reachy_phone_home.egg-info/PKG-INFO
9
+ reachy_phone_home.egg-info/SOURCES.txt
10
+ reachy_phone_home.egg-info/dependency_links.txt
11
+ reachy_phone_home.egg-info/entry_points.txt
12
+ reachy_phone_home.egg-info/requires.txt
13
+ reachy_phone_home.egg-info/top_level.txt
reachy_phone_home.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
reachy_phone_home.egg-info/entry_points.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [reachy_mini_apps]
2
+ reachy_phone_home = reachy_phone_home.main:ReachyPhoneHome
reachy_phone_home.egg-info/requires.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ reachy-mini
reachy_phone_home.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ reachy_phone_home
reachy_phone_home/__init__.py ADDED
File without changes
reachy_phone_home/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (182 Bytes). View file
 
reachy_phone_home/__pycache__/expressions.cpython-310.pyc ADDED
Binary file (2.5 kB). View file
 
reachy_phone_home/__pycache__/gestures.cpython-310.pyc ADDED
Binary file (6.77 kB). View file
 
reachy_phone_home/__pycache__/main.cpython-310.pyc ADDED
Binary file (10.3 kB). View file
 
reachy_phone_home/__pycache__/movements.cpython-310.pyc ADDED
Binary file (6.07 kB). View file
 
reachy_phone_home/main.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import itertools
3
+ import os
4
+ import queue
5
+ import random
6
+ import sys
7
+ import tempfile
8
+ import threading
9
+ import time
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from hashlib import sha1
13
+ from typing import Iterable, List, Optional, Tuple
14
+
15
+ from reachy_mini import ReachyMini, ReachyMiniApp
16
+
17
+ try:
18
+ from .movements import Movement, SituationMovements
19
+ except ImportError: # allow running as a script
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ sys.path.append(str(Path(__file__).resolve().parents[1]))
24
+ from reachy_phone_home.movements import Movement, SituationMovements
25
+
26
+
27
+ @dataclass
28
+ class SimulationConfig:
29
+ ambient_interval: float = 5.0
30
+ good_job_interval: float = 10.0
31
+ bad_interval: float = 5.0
32
+ min_gap_seconds: float = 5.0
33
+
34
+
35
+ class ElevenLabsSpeaker:
36
+ def __init__(
37
+ self,
38
+ reachy: ReachyMini,
39
+ voice_id: str,
40
+ model_id: str = "eleven_multilingual_v2",
41
+ output_format: str = "pcm_22050",
42
+ cache_dir: Optional[Path] = None,
43
+ ) -> None:
44
+ self.reachy = reachy
45
+ self.voice_id = voice_id
46
+ self.model_id = model_id
47
+ self.output_format = output_format
48
+ self.cache_dir = cache_dir
49
+ self._client = None
50
+
51
+ def _ensure_client(self) -> None:
52
+ if self._client is not None:
53
+ return
54
+ try:
55
+ from elevenlabs.client import ElevenLabs
56
+ except ImportError as exc:
57
+ raise SystemExit("Missing dependency: pip install elevenlabs") from exc
58
+ api_key = os.getenv("ELEVENLABS_API_KEY")
59
+ if not api_key:
60
+ raise SystemExit("ELEVENLABS_API_KEY is not set.")
61
+ self._client = ElevenLabs(api_key=api_key)
62
+
63
+ def _cache_path(self, text: str) -> Optional[Path]:
64
+ if self.cache_dir is None:
65
+ return None
66
+ key = f"{self.voice_id}|{self.model_id}|{self.output_format}|{text}"
67
+ digest = sha1(key.encode("utf-8")).hexdigest()[:12]
68
+ safe = "".join(ch for ch in text.lower() if ch.isalnum() or ch in (" ", "-"))
69
+ safe = "_".join(safe.split())[:32]
70
+ filename = f"{safe or 'quip'}_{digest}.wav"
71
+ return self.cache_dir / filename
72
+
73
+ def say(self, text: str) -> None:
74
+ if self.output_format.startswith("pcm_"):
75
+ sample_rate = int(self.output_format.split("_", 1)[1])
76
+ else:
77
+ raise SystemExit("Only PCM output is supported for now.")
78
+
79
+ cache_path = self._cache_path(text)
80
+ if cache_path is not None and cache_path.exists():
81
+ self.reachy.media.play_sound(str(cache_path))
82
+ return
83
+
84
+ self._ensure_client()
85
+ assert self._client is not None
86
+ audio = self._client.text_to_speech.convert(
87
+ text=text,
88
+ voice_id=self.voice_id,
89
+ model_id=self.model_id,
90
+ output_format=self.output_format,
91
+ )
92
+ audio_bytes = audio if isinstance(audio, (bytes, bytearray)) else b"".join(audio)
93
+ if cache_path is not None:
94
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
95
+ _write_pcm_wav(cache_path, audio_bytes, sample_rate)
96
+ self.reachy.media.play_sound(str(cache_path))
97
+ return
98
+
99
+ with tempfile.TemporaryDirectory() as tmp_dir:
100
+ wav_path = Path(tmp_dir) / "tts.wav"
101
+ _write_pcm_wav(wav_path, audio_bytes, sample_rate)
102
+ self.reachy.media.play_sound(str(wav_path))
103
+
104
+
105
+ def _write_pcm_wav(path: Path, pcm_bytes: bytes, sample_rate: int) -> None:
106
+ import wave
107
+
108
+ with wave.open(str(path), "wb") as wav_file:
109
+ wav_file.setnchannels(1)
110
+ wav_file.setsampwidth(2)
111
+ wav_file.setframerate(sample_rate)
112
+ wav_file.writeframes(pcm_bytes)
113
+
114
+
115
+ def _start_key_listener(stop_event: threading.Event) -> queue.Queue[str]:
116
+ q: queue.Queue[str] = queue.Queue()
117
+
118
+ def _reader() -> None:
119
+ if not sys.stdin.isatty():
120
+ return
121
+ import select
122
+ import termios
123
+ import tty
124
+
125
+ fd = sys.stdin.fileno()
126
+ old = termios.tcgetattr(fd)
127
+ try:
128
+ tty.setcbreak(fd)
129
+ while not stop_event.is_set():
130
+ r, _, _ = select.select([sys.stdin], [], [], 0.1)
131
+ if r:
132
+ ch = sys.stdin.read(1)
133
+ q.put(ch)
134
+ finally:
135
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
136
+
137
+ t = threading.Thread(target=_reader, daemon=True)
138
+ t.start()
139
+ return q
140
+
141
+
142
+ class ReachyPhoneHome(ReachyMiniApp):
143
+ def __init__(self, config: SimulationConfig | None = None) -> None:
144
+ super().__init__()
145
+ self.config = config or SimulationConfig()
146
+ self.enable_tts = True
147
+ self.voice_id = "KiWrnassWh6wLlOzZhGd"
148
+ self.model_id = "eleven_multilingual_v2"
149
+ self.output_format = "pcm_22050"
150
+ self.cache_dir: Optional[Path] = None
151
+
152
+ def _cycle_movements(self, moves: Iterable[Movement]) -> Iterable[Movement]:
153
+ return itertools.cycle(list(moves))
154
+
155
+ def _play_next(
156
+ self,
157
+ cycle_iter: Iterable[Movement],
158
+ label: str,
159
+ speaker: Optional[ElevenLabsSpeaker] = None,
160
+ quips: Optional[List[str]] = None,
161
+ ) -> None:
162
+ move = next(cycle_iter)
163
+ print(f"[{label}] {move.name}")
164
+ move.run()
165
+ if speaker and quips:
166
+ speaker.say(random.choice(quips))
167
+
168
+ def _run_state_loop(
169
+ self,
170
+ stop_event: threading.Event,
171
+ ambient: Iterable[Movement],
172
+ good_job: Iterable[Movement],
173
+ bad: Iterable[Movement],
174
+ speaker: Optional[ElevenLabsSpeaker],
175
+ placed_quips: List[str],
176
+ taken_quips: List[str],
177
+ good_quips: List[str],
178
+ bad_quips: List[str],
179
+ ) -> None:
180
+ phone_present = False
181
+ print("\nPress 1 = phone placed, 2 = phone taken, Ctrl+C to stop.")
182
+
183
+ key_queue = _start_key_listener(stop_event)
184
+ last_ambient = 0.0
185
+ last_good = 0.0
186
+ last_bad = 0.0
187
+ last_move = 0.0
188
+ last_speech = 0.0
189
+
190
+ while not stop_event.is_set():
191
+ try:
192
+ cmd = key_queue.get_nowait()
193
+ except queue.Empty:
194
+ cmd = None
195
+
196
+ if cmd == "1":
197
+ phone_present = True
198
+ print("[state] PHONE PRESENT")
199
+ if time.time() - last_move >= self.config.min_gap_seconds:
200
+ self._play_next(good_job, "good_job", speaker, good_quips)
201
+ last_move = time.time()
202
+ last_good = last_move
203
+ if speaker and time.time() - last_speech >= self.config.min_gap_seconds:
204
+ speaker.say(random.choice(placed_quips))
205
+ last_speech = time.time()
206
+ elif cmd == "2":
207
+ phone_present = False
208
+ print("[state] PHONE ABSENT")
209
+ if time.time() - last_move >= self.config.min_gap_seconds:
210
+ self._play_next(bad, "bad", speaker, bad_quips)
211
+ last_move = time.time()
212
+ last_bad = last_move
213
+ if speaker and time.time() - last_speech >= self.config.min_gap_seconds:
214
+ speaker.say(random.choice(taken_quips))
215
+ last_speech = time.time()
216
+ elif cmd is not None:
217
+ print("Use 1 (present) or 2 (absent).")
218
+
219
+ now = time.time()
220
+ if phone_present:
221
+ if (
222
+ now - last_good >= self.config.good_job_interval
223
+ and now - last_move >= self.config.min_gap_seconds
224
+ ):
225
+ self._play_next(good_job, "good_job", speaker, good_quips)
226
+ last_good = time.time()
227
+ last_move = last_good
228
+ elif (
229
+ now - last_ambient >= self.config.ambient_interval
230
+ and now - last_move >= self.config.min_gap_seconds
231
+ ):
232
+ self._play_next(ambient, "ambient")
233
+ last_ambient = time.time()
234
+ last_move = last_ambient
235
+ else:
236
+ if (
237
+ now - last_bad >= self.config.bad_interval
238
+ and now - last_move >= self.config.min_gap_seconds
239
+ ):
240
+ self._play_next(bad, "bad", speaker, bad_quips)
241
+ last_bad = time.time()
242
+ last_move = last_bad
243
+ time.sleep(0.1)
244
+
245
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
246
+ moves = SituationMovements(reachy_mini)
247
+
248
+ ambient_cycle = self._cycle_movements(moves.ambient())
249
+ good_cycle = self._cycle_movements(moves.good_job())
250
+ bad_cycle = self._cycle_movements(moves.bad())
251
+
252
+ placed_quips = [
253
+ "Nice. Phone is parked. Let's focus.",
254
+ "Great. Phone down. You've got this.",
255
+ "All set. Phone is home.",
256
+ "Perfect. Phone secured.",
257
+ ]
258
+ taken_quips = [
259
+ "Hey, the phone left its spot.",
260
+ "Phone's gone. Want to put it back?",
261
+ "Uh oh. Phone off the desk.",
262
+ "Phone is away. Let's bring it back.",
263
+ ]
264
+ good_quips = [
265
+ "Nice work keeping the phone in place.",
266
+ "Focus mode: still on.",
267
+ "Great job staying on track.",
268
+ ]
269
+ bad_quips = [
270
+ "The phone's tempting you again.",
271
+ "Let's put it back and refocus.",
272
+ "Phone away, focus goes stronger.",
273
+ ]
274
+
275
+ speaker = None
276
+ if self.enable_tts and not stop_event.is_set():
277
+ try:
278
+ speaker = ElevenLabsSpeaker(
279
+ reachy=reachy_mini,
280
+ voice_id=self.voice_id,
281
+ model_id=self.model_id,
282
+ output_format=self.output_format,
283
+ cache_dir=self.cache_dir,
284
+ )
285
+ except Exception:
286
+ speaker = None
287
+
288
+ print("Starting Reachy Phone Home (manual state simulation).")
289
+ self._run_state_loop(
290
+ stop_event=stop_event,
291
+ ambient=ambient_cycle,
292
+ good_job=good_cycle,
293
+ bad=bad_cycle,
294
+ speaker=speaker,
295
+ placed_quips=placed_quips,
296
+ taken_quips=taken_quips,
297
+ good_quips=good_quips,
298
+ bad_quips=bad_quips,
299
+ )
300
+
301
+
302
+ if __name__ == "__main__":
303
+ # You can run the app directly from this script
304
+ parser = argparse.ArgumentParser(description="Reachy Phone Home app (simulated)")
305
+ parser.add_argument("--ambient-interval", type=float, default=5.0)
306
+ parser.add_argument("--good-job-interval", type=float, default=10.0)
307
+ parser.add_argument("--bad-interval", type=float, default=5.0)
308
+ parser.add_argument("--min-gap", type=float, default=5.0)
309
+ parser.add_argument("--no-media", action="store_true", help="Disable media backend")
310
+ parser.add_argument("--tts", action="store_true", help="Enable TTS")
311
+ parser.add_argument("--voice-id", type=str, default="KiWrnassWh6wLlOzZhGd")
312
+ parser.add_argument("--model-id", type=str, default="eleven_multilingual_v2")
313
+ parser.add_argument("--output-format", type=str, default="pcm_22050")
314
+ parser.add_argument(
315
+ "--cache-dir",
316
+ type=str,
317
+ default=str(Path(__file__).resolve().parent / "assets" / "quips"),
318
+ help="Directory to cache ElevenLabs quips",
319
+ )
320
+ parser.add_argument(
321
+ "--prewarm-quips",
322
+ action="store_true",
323
+ help="Download and cache all quips, then exit",
324
+ )
325
+ args = parser.parse_args()
326
+
327
+ config = SimulationConfig(
328
+ ambient_interval=args.ambient_interval,
329
+ good_job_interval=args.good_job_interval,
330
+ bad_interval=args.bad_interval,
331
+ min_gap_seconds=args.min_gap,
332
+ )
333
+
334
+ with ReachyMini(media_backend="no_media" if args.no_media else "default") as mini:
335
+ app = ReachyPhoneHome(config=config)
336
+ app.enable_tts = args.tts and not args.no_media
337
+ app.voice_id = args.voice_id
338
+ app.model_id = args.model_id
339
+ app.output_format = args.output_format
340
+ app.cache_dir = Path(args.cache_dir)
341
+
342
+ if args.prewarm_quips:
343
+ app.enable_tts = True
344
+ speaker = ElevenLabsSpeaker(
345
+ reachy=mini,
346
+ voice_id=app.voice_id,
347
+ model_id=app.model_id,
348
+ output_format=app.output_format,
349
+ cache_dir=app.cache_dir,
350
+ )
351
+ all_quips = [
352
+ "Nice. Phone is parked. Let's focus.",
353
+ "Great. Phone down. You've got this.",
354
+ "All set. Phone is home.",
355
+ "Perfect. Phone secured.",
356
+ "Hey, the phone left its spot.",
357
+ "Phone's gone. Want to put it back?",
358
+ "Uh oh. Phone off the desk.",
359
+ "Phone is away. Let's bring it back.",
360
+ "Nice work keeping the phone in place.",
361
+ "Focus mode: still on.",
362
+ "Great job staying on track.",
363
+ "The phone's tempting you again.",
364
+ "Let's put it back and refocus.",
365
+ "Phone away, focus goes stronger.",
366
+ ]
367
+ for quip in all_quips:
368
+ speaker.say(quip)
369
+ print(f"Cached {len(all_quips)} quips in {app.cache_dir}")
370
+ sys.exit(0)
371
+
372
+ stop = threading.Event()
373
+
374
+ try:
375
+ print("Running 'reachy_phone_home' a ReachyMiniApp...")
376
+ print("Press Ctrl+C to stop the app.")
377
+ app.run(mini, stop)
378
+ print("App has stopped.")
379
+
380
+ except KeyboardInterrupt:
381
+ print("Stopping the app...")
382
+ stop.set()
reachy_phone_home_app.py ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reachy phone tracking + phone-use detection using YOLO26l (no look-down logic)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import math
8
+ import os
9
+ import time
10
+ from pathlib import Path
11
+
12
+ import cv2
13
+ from ultralytics import YOLO
14
+
15
+ from reachy_mini import ReachyMini
16
+ from reachy_mini.utils import create_head_pose
17
+
18
+ try:
19
+ from reachy_phone_home.movements import MovementScheduler, SituationMovements
20
+ except ModuleNotFoundError:
21
+ from pathlib import Path
22
+ import sys
23
+
24
+ repo_root = Path(__file__).resolve().parents[1]
25
+ sys.path.insert(0, str(repo_root))
26
+ from reachy_phone_home.movements import MovementScheduler, SituationMovements
27
+
28
+
29
+ PHONE_CLASS_ID = 67 # COCO "cell phone"
30
+ PERSON_CLASS_ID = 0
31
+
32
+
33
+ class PhoneFollower:
34
+ def __init__(self, move_threshold_px: int = 60, head_duration: float = 0.6) -> None:
35
+ self.move_threshold_px = int(move_threshold_px)
36
+ self.head_duration = float(head_duration)
37
+ self.last_x = None
38
+ self.last_y = None
39
+
40
+ def update_box(self, reachy: ReachyMini, box, y_ratio: float = 0.5) -> None:
41
+ x1, y1, x2, y2 = _box_xyxy(box)
42
+ cx = int((x1 + x2) / 2)
43
+ cy = int(y1 + (y2 - y1) * y_ratio)
44
+ self.update_xy(reachy, cx, cy)
45
+
46
+ def update_xy(self, reachy: ReachyMini, cx: int, cy: int) -> None:
47
+ if self.last_x is not None and self.last_y is not None:
48
+ if (
49
+ abs(cx - self.last_x) < self.move_threshold_px
50
+ and abs(cy - self.last_y) < self.move_threshold_px
51
+ ):
52
+ return
53
+ reachy.look_at_image(cx, cy, duration=self.head_duration, perform_movement=True)
54
+ self.last_x = cx
55
+ self.last_y = cy
56
+
57
+
58
+ def _box_xyxy(box) -> tuple[int, int, int, int]:
59
+ xyxy = box.xyxy[0].tolist()
60
+ return int(xyxy[0]), int(xyxy[1]), int(xyxy[2]), int(xyxy[3])
61
+
62
+
63
+ def _overlaps(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> bool:
64
+ ax1, ay1, ax2, ay2 = a
65
+ bx1, by1, bx2, by2 = b
66
+ return not (ax2 < bx1 or ax1 > bx2 or ay2 < by1 or ay1 > by2)
67
+
68
+
69
+ def _pad_box(box: tuple[int, int, int, int], pad_ratio: float) -> tuple[int, int, int, int]:
70
+ x1, y1, x2, y2 = box
71
+ bw = max(1, x2 - x1)
72
+ bh = max(1, y2 - y1)
73
+ px = int(bw * pad_ratio)
74
+ py = int(bh * pad_ratio)
75
+ return x1 - px, y1 - py, x2 + px, y2 + py
76
+
77
+
78
+ def _clamp_box(
79
+ box: tuple[int, int, int, int], frame_w: int, frame_h: int
80
+ ) -> tuple[int, int, int, int]:
81
+ x1, y1, x2, y2 = box
82
+ x1 = max(0, min(frame_w - 1, x1))
83
+ x2 = max(0, min(frame_w - 1, x2))
84
+ y1 = max(0, min(frame_h - 1, y1))
85
+ y2 = max(0, min(frame_h - 1, y2))
86
+ if x2 < x1:
87
+ x1, x2 = x2, x1
88
+ if y2 < y1:
89
+ y1, y2 = y2, y1
90
+ return x1, y1, x2, y2
91
+
92
+
93
+ def _resolve_weights(weights: str, logger: logging.Logger) -> str:
94
+ if weights in ("yolo26l", "yolo26m", "yolo26s", "yolo26n"):
95
+ filename = f"{weights}.pt"
96
+ cache_root = os.getenv("HF_HOME") or str(Path.home() / ".cache" / "reachy_phone_home")
97
+ cache_dir = Path(cache_root) / "models"
98
+ cache_path = cache_dir / filename
99
+ if not cache_path.exists():
100
+ cache_dir.mkdir(parents=True, exist_ok=True)
101
+ url = f"https://huggingface.co/Ultralytics/YOLO26/resolve/main/{filename}"
102
+ logger.info("Downloading %s to %s", url, cache_path)
103
+ _download_file(url, cache_path)
104
+ return str(cache_path)
105
+ return weights
106
+
107
+
108
+ def _download_file(url: str, dest: Path) -> None:
109
+ import urllib.request
110
+
111
+ with urllib.request.urlopen(url) as response, open(dest, "wb") as out_file:
112
+ out_file.write(response.read())
113
+
114
+
115
+ def main() -> None:
116
+ parser = argparse.ArgumentParser(description="Reachy phone use tracker (YOLO26l)")
117
+ parser.add_argument("--weights", type=str, default="yolo26l")
118
+ parser.add_argument("--conf", type=float, default=0.15)
119
+ parser.add_argument("--process-every", type=int, default=1)
120
+ parser.add_argument("--imgsz", type=int, default=640)
121
+ parser.add_argument("--head-duration", type=float, default=1.2)
122
+ parser.add_argument("--move-threshold-px", type=int, default=60)
123
+ parser.add_argument("--no-head", action="store_true")
124
+ parser.add_argument("--phone-use-confirm-sec", type=float, default=0.5)
125
+ parser.add_argument("--phone-use-clear-sec", type=float, default=0.5)
126
+ parser.add_argument("--phone-not-seen-clear-sec", type=float, default=1.0)
127
+ parser.add_argument("--pad", type=float, default=0.1)
128
+ parser.add_argument("--missing-neutral-sec", type=float, default=0.5)
129
+ parser.add_argument("--neutral-duration", type=float, default=1.2)
130
+ parser.add_argument("--person-y-ratio", type=float, default=0.3)
131
+ parser.add_argument("--look-down-after-sec", type=float, default=5.0)
132
+ parser.add_argument("--look-down-duration", type=float, default=1.2)
133
+ parser.add_argument("--look-down-z-mm", type=float, default=8.0)
134
+ parser.add_argument("--look-down-pitch-deg", type=float, default=30.0)
135
+ parser.add_argument("--look-down-window-sec", type=float, default=5.0)
136
+ parser.add_argument("--person-search-window-sec", type=float, default=5.0)
137
+ parser.add_argument("--no-antenna", action="store_true")
138
+ parser.add_argument("--antenna-angry-left", type=float, default=-2.6)
139
+ parser.add_argument("--antenna-angry-right", type=float, default=2.6)
140
+ parser.add_argument("--antenna-neutral-left", type=float, default=0.0)
141
+ parser.add_argument("--antenna-neutral-right", type=float, default=0.0)
142
+ parser.add_argument("--antenna-transition-sec", type=float, default=0.5)
143
+ parser.add_argument("--antenna-relax-sec", type=float, default=1.0)
144
+ parser.add_argument("--antenna-happy-amp", type=float, default=0.2)
145
+ parser.add_argument("--antenna-happy-duration", type=float, default=0.5)
146
+ parser.add_argument("--good-job-heartbeats", type=int, default=3)
147
+ parser.add_argument("--phone-use-bad-sec", type=float, default=10.0)
148
+ parser.add_argument("--movement-restore-sec", type=float, default=0.6)
149
+ parser.add_argument("--no-display", action="store_true")
150
+ args = parser.parse_args()
151
+
152
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
153
+ logger = logging.getLogger("yolo26l_phone_use_tracker")
154
+
155
+ weights = _resolve_weights(args.weights, logger)
156
+ model = YOLO(weights)
157
+
158
+ with ReachyMini() as reachy:
159
+ follower = PhoneFollower(
160
+ move_threshold_px=args.move_threshold_px,
161
+ head_duration=args.head_duration,
162
+ )
163
+ movements = SituationMovements(reachy)
164
+ scheduler = MovementScheduler(
165
+ movements,
166
+ good_job_heartbeats=args.good_job_heartbeats,
167
+ phone_use_bad_sec=args.phone_use_bad_sec,
168
+ phone_use_clear_sec=args.phone_use_clear_sec,
169
+ restore_head_duration=args.movement_restore_sec,
170
+ )
171
+ if not args.no_display:
172
+ cv2.namedWindow("YOLO26L Phone Use")
173
+
174
+ last_phone_use = 0.0
175
+ last_phone_use_state = False
176
+ phone_use_start = None
177
+ phone_use_stop = None
178
+ frame_count = 0
179
+ last_phone_label = None
180
+ phone_label_miss = 0
181
+ last_antenna_mode = "neutral"
182
+ last_phone_seen = time.time()
183
+ neutral_active = False
184
+ look_down_active = False
185
+ person_track_start = None
186
+ missing_start = None
187
+ last_heartbeat = time.time()
188
+ last_track_state = None
189
+ oscillate_start = None
190
+ last_oscillate_update = 0.0
191
+ look_down_cycles = 0
192
+ last_prompt = 0.0
193
+ mode = "tracking_phone"
194
+ mode_start = time.time()
195
+
196
+ while True:
197
+ frame = reachy.media.get_frame()
198
+ if frame is None:
199
+ continue
200
+
201
+ frame_count += 1
202
+ if args.process_every > 1 and frame_count % max(1, args.process_every) != 0:
203
+ if not args.no_display:
204
+ cv2.imshow("YOLO26L Phone Use", frame)
205
+ if cv2.waitKey(1) & 0xFF == ord("q"):
206
+ break
207
+ continue
208
+
209
+ results = model(
210
+ frame,
211
+ verbose=False,
212
+ classes=[PERSON_CLASS_ID, PHONE_CLASS_ID],
213
+ conf=args.conf,
214
+ imgsz=args.imgsz,
215
+ )
216
+ boxes = results[0].boxes if results else None
217
+ person_boxes = []
218
+ phone_boxes = []
219
+ if boxes is not None and hasattr(boxes, "cls"):
220
+ for i in range(len(boxes)):
221
+ cls = int(boxes.cls[i].item())
222
+ if cls == PERSON_CLASS_ID:
223
+ person_boxes.append(boxes[i])
224
+ elif cls == PHONE_CLASS_ID:
225
+ phone_boxes.append(boxes[i])
226
+
227
+ phone_seen = len(phone_boxes) > 0
228
+ phone_use = False
229
+ best_phone = None
230
+ if person_boxes and phone_boxes:
231
+ best_person = max(
232
+ person_boxes,
233
+ key=lambda b: float(b.conf[0].item()) if hasattr(b, "conf") else 0.0,
234
+ )
235
+ pxyxy = _pad_box(_box_xyxy(best_person), args.pad)
236
+ for phbox in phone_boxes:
237
+ phxyxy = _pad_box(_box_xyxy(phbox), args.pad)
238
+ if _overlaps(pxyxy, phxyxy):
239
+ phone_use = True
240
+ break
241
+ if phone_boxes:
242
+ best_phone = max(
243
+ phone_boxes,
244
+ key=lambda b: float(b.conf[0].item()) if hasattr(b, "conf") else 0.0,
245
+ )
246
+ if not args.no_head:
247
+ follower.update_box(reachy, best_phone, y_ratio=0.5)
248
+ last_phone_seen = time.time()
249
+ neutral_active = False
250
+ look_down_active = False
251
+ person_track_start = None
252
+ missing_start = None
253
+ look_down_cycles = 0
254
+ last_prompt = 0.0
255
+ mode = "tracking_phone"
256
+ mode_start = time.time()
257
+
258
+ if not phone_seen and time.time() - last_phone_seen >= args.missing_neutral_sec:
259
+ if missing_start is None:
260
+ missing_start = time.time()
261
+ if not neutral_active:
262
+ reachy.goto_target(head=create_head_pose(), duration=args.neutral_duration)
263
+ neutral_active = True
264
+ if mode == "tracking_phone":
265
+ mode = "tracking_person"
266
+ mode_start = time.time()
267
+ look_down_active = False
268
+
269
+ if mode == "tracking_person":
270
+ if person_boxes and not args.no_head:
271
+ best_person = max(
272
+ person_boxes,
273
+ key=lambda b: float(b.conf[0].item()) if hasattr(b, "conf") else 0.0,
274
+ )
275
+ follower.update_box(reachy, best_person, y_ratio=args.person_y_ratio)
276
+ if time.time() - mode_start >= args.person_search_window_sec:
277
+ mode = "looking_down"
278
+ mode_start = time.time()
279
+ look_down_active = False
280
+
281
+ if mode == "looking_down":
282
+ if not look_down_active:
283
+ reachy.goto_target(
284
+ head=create_head_pose(
285
+ z=args.look_down_z_mm,
286
+ pitch=args.look_down_pitch_deg,
287
+ mm=True,
288
+ degrees=True,
289
+ ),
290
+ duration=args.look_down_duration,
291
+ )
292
+ look_down_active = True
293
+ look_down_cycles += 1
294
+ if look_down_cycles >= 2 and time.time() - last_prompt >= 10.0:
295
+ logger.info("Can you please put the phone in front of me?")
296
+ last_prompt = time.time()
297
+ if time.time() - mode_start >= args.look_down_window_sec:
298
+ mode = "tracking_person"
299
+ mode_start = time.time()
300
+
301
+ if phone_seen:
302
+ track_state = "tracking_phone"
303
+ elif mode == "looking_down":
304
+ track_state = "looking_down"
305
+ elif mode == "tracking_person":
306
+ track_state = "tracking_person"
307
+ else:
308
+ track_state = "searching_phone"
309
+ if track_state != last_track_state:
310
+ logger.info("[state] %s", track_state)
311
+ last_track_state = track_state
312
+
313
+ if phone_use:
314
+ last_phone_use = time.time()
315
+ phone_use_stop = None
316
+ if phone_use_start is None:
317
+ phone_use_start = time.time()
318
+ if not last_phone_use_state and time.time() - phone_use_start >= args.phone_use_confirm_sec:
319
+ logger.info("[state] phone use detected")
320
+ last_phone_use_state = True
321
+ else:
322
+ phone_use_start = None
323
+ if phone_seen:
324
+ if phone_use_stop is None:
325
+ phone_use_stop = time.time()
326
+ if last_phone_use_state and time.time() - phone_use_stop >= args.phone_use_clear_sec:
327
+ logger.info("[state] phone use stopped")
328
+ last_phone_use_state = False
329
+ else:
330
+ phone_use_stop = None
331
+ if last_phone_use_state and time.time() - last_phone_use >= args.phone_not_seen_clear_sec:
332
+ logger.info("[state] phone use stopped")
333
+ last_phone_use_state = False
334
+
335
+ if time.time() - last_heartbeat >= 10.0:
336
+ logger.info("[heartbeat] phone_detected=%s", "yes" if phone_seen else "no")
337
+ if (
338
+ not args.no_antenna
339
+ and not last_phone_use_state
340
+ and last_track_state == "tracking_phone"
341
+ and phone_seen
342
+ ):
343
+ oscillate_start = time.time()
344
+ last_heartbeat = time.time()
345
+ scheduler.on_heartbeat(
346
+ phone_tracked=(last_track_state == "tracking_phone"),
347
+ phone_use=last_phone_use_state,
348
+ )
349
+
350
+ if not args.no_antenna:
351
+ if last_phone_use_state:
352
+ antenna_mode = "angry"
353
+ elif phone_seen:
354
+ antenna_mode = "tracking"
355
+ else:
356
+ antenna_mode = "neutral"
357
+
358
+ if antenna_mode != last_antenna_mode:
359
+ if antenna_mode == "angry":
360
+ reachy.goto_target(
361
+ antennas=[args.antenna_angry_left, args.antenna_angry_right],
362
+ duration=args.antenna_transition_sec,
363
+ )
364
+ else:
365
+ duration = (
366
+ args.antenna_relax_sec
367
+ if last_antenna_mode == "angry"
368
+ else args.antenna_transition_sec
369
+ )
370
+ reachy.goto_target(
371
+ antennas=[args.antenna_neutral_left, args.antenna_neutral_right],
372
+ duration=duration,
373
+ )
374
+ last_antenna_mode = antenna_mode
375
+
376
+ if oscillate_start is not None and antenna_mode == "tracking":
377
+ elapsed = time.time() - oscillate_start
378
+ if elapsed <= args.antenna_happy_duration:
379
+ now = time.time()
380
+ if now - last_oscillate_update >= 0.05:
381
+ t = elapsed / args.antenna_happy_duration
382
+ val = args.antenna_happy_amp * math.sin(-math.pi / 2 + math.pi * t)
383
+ reachy.set_target(antennas=(val, -val))
384
+ last_oscillate_update = now
385
+ else:
386
+ oscillate_start = None
387
+
388
+ if not args.no_display:
389
+ display = frame.copy()
390
+ frame_h, frame_w = display.shape[:2]
391
+ if person_boxes:
392
+ best_person = max(
393
+ person_boxes,
394
+ key=lambda b: float(b.conf[0].item()) if hasattr(b, "conf") else 0.0,
395
+ )
396
+ x1, y1, x2, y2 = _clamp_box(
397
+ _pad_box(_box_xyxy(best_person), args.pad), frame_w, frame_h
398
+ )
399
+ cv2.rectangle(display, (x1, y1), (x2, y2), (0, 165, 255), 2)
400
+ if hasattr(best_person, "conf"):
401
+ conf = float(best_person.conf[0].item())
402
+ cv2.putText(
403
+ display,
404
+ f"{conf:.2f}",
405
+ (x1, max(20, y1 - 6)),
406
+ cv2.FONT_HERSHEY_SIMPLEX,
407
+ 0.5,
408
+ (0, 165, 255),
409
+ 2,
410
+ )
411
+ if best_phone is not None:
412
+ x1, y1, x2, y2 = _clamp_box(
413
+ _pad_box(_box_xyxy(best_phone), args.pad), frame_w, frame_h
414
+ )
415
+ cv2.rectangle(display, (x1, y1), (x2, y2), (0, 255, 0), 2)
416
+ conf = float(best_phone.conf[0].item()) if hasattr(best_phone, "conf") else None
417
+ label = f"phone {conf:.2f}" if conf is not None else "phone"
418
+ last_phone_label = (x1, y1, label)
419
+ phone_label_miss = 0
420
+ else:
421
+ phone_label_miss += 1
422
+ if phone_label_miss >= 5:
423
+ last_phone_label = None
424
+ if last_phone_label is not None:
425
+ x1, y1, label = last_phone_label
426
+ cv2.putText(
427
+ display,
428
+ label,
429
+ (x1, max(20, y1 - 6)),
430
+ cv2.FONT_HERSHEY_SIMPLEX,
431
+ 0.6,
432
+ (0, 255, 0),
433
+ 2,
434
+ cv2.LINE_AA,
435
+ )
436
+ if last_phone_use_state:
437
+ cv2.rectangle(display, (5, 5), (230, 55), (0, 0, 255), -1)
438
+ cv2.putText(
439
+ display,
440
+ "PHONE USE",
441
+ (12, 42),
442
+ cv2.FONT_HERSHEY_SIMPLEX,
443
+ 1.2,
444
+ (255, 255, 255),
445
+ 3,
446
+ )
447
+ cv2.imshow("YOLO26L Phone Use", display)
448
+ if cv2.waitKey(1) & 0xFF == ord("q"):
449
+ break
450
+
451
+ if not args.no_display:
452
+ cv2.destroyAllWindows()
453
+
454
+
455
+ if __name__ == "__main__":
456
+ main()
setup.cfg ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [metadata]
2
+ name = reachy_phone_home
3
+ version = 0.1.0
4
+ description = Phone focus companion for Reachy Mini
5
+
6
+ [options]
7
+ packages = find:
8
+ python_requires = >=3.10
9
+
10
+ [options.entry_points]
11
+ reachy_mini_apps =
12
+ reachy_phone_home = reachy_phone_home.main:ReachyPhoneHome
setup.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from distutils.core import setup
2
+
3
+ setup()
style.css ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #f7f4ed;
3
+ --ink: #1f1d1a;
4
+ --muted: #5e5a52;
5
+ --accent: #ffcc4d;
6
+ --card: #ffffff;
7
+ }
8
+
9
+ * {
10
+ box-sizing: border-box;
11
+ }
12
+
13
+ body {
14
+ margin: 0;
15
+ font-family: "IBM Plex Sans", "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
16
+ background: radial-gradient(circle at top, #fff5d6, var(--bg) 45%, #eef0f2 100%);
17
+ color: var(--ink);
18
+ }
19
+
20
+ .page {
21
+ max-width: 960px;
22
+ margin: 0 auto;
23
+ padding: 48px 24px 72px;
24
+ }
25
+
26
+ .hero {
27
+ background: var(--card);
28
+ border-radius: 20px;
29
+ padding: 32px;
30
+ box-shadow: 0 18px 40px rgba(31, 29, 26, 0.08);
31
+ }
32
+
33
+ .badge {
34
+ display: inline-block;
35
+ padding: 6px 12px;
36
+ border-radius: 999px;
37
+ background: var(--accent);
38
+ font-weight: 600;
39
+ font-size: 12px;
40
+ letter-spacing: 0.08em;
41
+ text-transform: uppercase;
42
+ }
43
+
44
+ .hero h1 {
45
+ margin: 16px 0 12px;
46
+ font-size: 40px;
47
+ letter-spacing: -0.02em;
48
+ }
49
+
50
+ .hero p {
51
+ margin: 0;
52
+ color: var(--muted);
53
+ font-size: 18px;
54
+ line-height: 1.6;
55
+ }
56
+
57
+ .cta {
58
+ margin-top: 24px;
59
+ padding: 14px 18px;
60
+ background: #fff2c2;
61
+ border-radius: 12px;
62
+ font-size: 14px;
63
+ display: inline-flex;
64
+ gap: 8px;
65
+ align-items: center;
66
+ flex-wrap: wrap;
67
+ }
68
+
69
+ .cta code {
70
+ background: #fff;
71
+ padding: 6px 10px;
72
+ border-radius: 8px;
73
+ font-size: 13px;
74
+ }
75
+
76
+ .grid {
77
+ margin-top: 28px;
78
+ display: grid;
79
+ gap: 18px;
80
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
81
+ }
82
+
83
+ .card {
84
+ background: var(--card);
85
+ border-radius: 16px;
86
+ padding: 20px;
87
+ box-shadow: 0 10px 24px rgba(31, 29, 26, 0.06);
88
+ }
89
+
90
+ .card h2 {
91
+ margin-top: 0;
92
+ font-size: 18px;
93
+ }
94
+
95
+ .card p {
96
+ margin-bottom: 0;
97
+ color: var(--muted);
98
+ }