add LED blink and Morse code scripts

This commit is contained in:
2026-03-01 06:16:33 +01:00
parent b1f5f4ee61
commit 06fe74975b
3 changed files with 296 additions and 3 deletions

View File

@@ -35,7 +35,33 @@ wrapping the private UniFi Protect API.
## Scripts
```sh
uv run dump_camera.py # dump camera data from integration + private API
uv run play_speaker.py # play hello.wav through the camera speaker
uv run record_mic.py # record from the camera mic (Ctrl+C to stop)
uv run dump_camera.py # dump camera data from integration + private API
uv run blink_led.py # blink the status LED
uv run morse.py "HELLO WORLD" # transmit Morse code on speaker + LED in sync
```
### `blink_led.py`
Toggles the status LED on and off, then restores its original state on exit.
```sh
uv run blink_led.py # 10 blinks at 0.5s interval (default)
uv run blink_led.py --count 20 --interval 0.3
```
### `morse.py`
Encodes a message as Morse code, plays a 700 Hz tone through the talkback speaker,
and blinks the status LED in sync.
```sh
uv run morse.py "SOS" # default: SOS at 0.2s unit
uv run morse.py "BACK HOME" --unit 0.25 # slower speed
uv run morse.py "SOS" --volume 80 # set speaker volume first (0-100)
uv run morse.py "SOS" --lead-in 0.8 # longer silence before morse starts
```
Standard Morse timing: dot = 1 unit, dash = 3 units, symbol gap = 1 unit,
letter gap = 3 units, word gap = 7 units.

54
blink_led.py Normal file
View File

@@ -0,0 +1,54 @@
import argparse
import os
import time
import httpx
from dotenv import load_dotenv
load_dotenv()
HOST = os.environ["HOST"]
BASE = f"https://{HOST}/proxy/protect/integration/v1"
HEADERS = {"X-API-Key": os.environ["API_KEY"]}
CAMERA_NAME = "G6 Pro 360"
def set_led(client: httpx.Client, camera_id: str, enabled: bool):
client.patch(
f"{BASE}/cameras/{camera_id}",
json={"ledSettings": {"isEnabled": enabled}},
).raise_for_status()
def main():
parser = argparse.ArgumentParser(description="Blink the G6 Pro 360 status LED")
parser.add_argument("--count", type=int, default=10, help="Number of blink cycles (default: 10)")
parser.add_argument("--interval", type=float, default=0.5, help="Seconds per on/off half-cycle (default: 0.5)")
args = parser.parse_args()
with httpx.Client(headers=HEADERS, verify=False) as client:
cameras = client.get(f"{BASE}/cameras").raise_for_status().json()
camera = next(c for c in cameras if CAMERA_NAME in c["name"])
camera_id = camera["id"]
original_led = camera["ledSettings"]["isEnabled"]
print(f"Camera: {camera['name']} ({camera_id})")
print(f"LED was: {'on' if original_led else 'off'}")
print(f"Blinking {args.count} times, {args.interval}s interval — Ctrl+C to stop early")
try:
for i in range(args.count):
set_led(client, camera_id, True)
print(f" [{i+1}/{args.count}] ON", flush=True)
time.sleep(args.interval)
set_led(client, camera_id, False)
print(f" [{i+1}/{args.count}] OFF", flush=True)
time.sleep(args.interval)
except KeyboardInterrupt:
print("\nInterrupted.")
finally:
set_led(client, camera_id, original_led)
print(f"Restored LED to: {'on' if original_led else 'off'}")
if __name__ == "__main__":
main()

213
morse.py Normal file
View File

@@ -0,0 +1,213 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["httpx[http2]", "python-dotenv"]
# ///
"""Transmit a message as Morse code on the camera speaker and status LED in sync."""
import argparse
import array
import math
import os
import subprocess
import tempfile
import threading
import time
import httpx
from dotenv import load_dotenv
load_dotenv()
HOST = os.environ["HOST"]
BASE = f"https://{HOST}/proxy/protect/integration/v1"
PRIVATE_BASE = f"https://{HOST}/proxy/protect/api"
HEADERS = {"X-API-Key": os.environ["API_KEY"]}
USERNAME = os.environ["UNIFI_USERNAME"]
PASSWORD = os.environ["UNIFI_PASSWORD"]
CAMERA_NAME = "G6 Pro 360"
TONE_FREQ = 700 # Hz
MORSE = {
'A': '.-', 'B': '-...', 'C': '-.-.', 'D': '-..', 'E': '.',
'F': '..-.', 'G': '--.', 'H': '....', 'I': '..', 'J': '.---',
'K': '-.-', 'L': '.-..', 'M': '--', 'N': '-.', 'O': '---',
'P': '.--.', 'Q': '--.-', 'R': '.-.', 'S': '...', 'T': '-',
'U': '..-', 'V': '...-', 'W': '.--', 'X': '-..-', 'Y': '-.--',
'Z': '--..',
'0': '-----', '1': '.----', '2': '..---', '3': '...--', '4': '....-',
'5': '.....', '6': '-....', '7': '--...', '8': '---..', '9': '----.',
'.': '.-.-.-', ',': '--..--', '?': '..--..', '/': '-..-.', '=': '-...-',
}
def set_speaker_volume(camera_id: str, volume: int):
# Accept-Encoding must be suppressed — server returns 403 when present
with httpx.Client(verify=False, http2=True) as client:
res = client.post(
f"https://{HOST}/api/auth/login",
content=f'{{"username":"{USERNAME}","password":"{PASSWORD}","rememberMe":false}}'.encode(),
headers={"Content-Type": "application/json", "Accept-Encoding": ""},
)
res.raise_for_status()
csrf = res.headers.get("x-csrf-token")
client.patch(
f"{PRIVATE_BASE}/cameras/{camera_id}",
headers={"x-csrf-token": csrf},
json={"speakerSettings": {"volume": volume, "speakerVolume": volume}},
).raise_for_status()
print(f"Volume : {volume}")
def text_to_sequence(text: str, unit: float) -> list[tuple[bool, float]]:
"""Convert text to a list of (is_on, duration_seconds) pulses."""
seq: list[tuple[bool, float]] = []
words = text.upper().split()
for wi, word in enumerate(words):
letters = [ch for ch in word if ch in MORSE]
for li, ch in enumerate(letters):
code = MORSE[ch]
for si, sym in enumerate(code):
seq.append((True, unit if sym == '.' else 3 * unit))
if si < len(code) - 1:
seq.append((False, unit)) # inter-symbol gap
if li < len(letters) - 1:
seq.append((False, 3 * unit)) # inter-letter gap
if wi < len(words) - 1:
seq.append((False, 7 * unit)) # inter-word gap
return seq
def generate_pcm(sequence: list[tuple[bool, float]], sample_rate: int, lead_in: float) -> bytes:
"""Generate signed 16-bit little-endian PCM for the morse sequence."""
FADE = int(0.005 * sample_rate) # 5ms linear fade in/out to avoid clicks
samples: array.array = array.array('h')
samples.extend([0] * int(lead_in * sample_rate))
t = 0
for is_on, duration in sequence:
n = int(duration * sample_rate)
if is_on:
for i in range(n):
if i < FADE:
env = i / FADE
elif i >= n - FADE:
env = (n - i) / FADE
else:
env = 1.0
val = int(32767 * 0.8 * env * math.sin(2 * math.pi * TONE_FREQ * (t + i) / sample_rate))
samples.append(val)
else:
samples.extend([0] * n)
t += n
return samples.tobytes()
def _sleep_interruptible(duration: float, stop: threading.Event):
deadline = time.monotonic() + duration
while not stop.is_set():
left = deadline - time.monotonic()
if left <= 0:
break
time.sleep(min(left, 0.02))
def led_worker(camera_id: str, sequence: list[tuple[bool, float]], lead_in: float, original_led: bool, stop: threading.Event):
"""Toggle the LED in a dedicated thread with its own HTTP client."""
with httpx.Client(headers=HEADERS, verify=False) as client:
def set_led(state: bool):
client.patch(
f"{BASE}/cameras/{camera_id}",
json={"ledSettings": {"isEnabled": state}},
).raise_for_status()
try:
_sleep_interruptible(lead_in, stop)
for is_on, duration in sequence:
if stop.is_set():
break
t0 = time.monotonic()
set_led(is_on)
remaining = duration - (time.monotonic() - t0)
_sleep_interruptible(remaining, stop)
finally:
set_led(original_led)
def main():
parser = argparse.ArgumentParser(description="Morse code on camera speaker + LED")
parser.add_argument("message", nargs="?", default="SOS", help="Message to transmit (default: SOS)")
parser.add_argument("--unit", type=float, default=0.2, help="Unit duration in seconds (default: 0.2)")
parser.add_argument("--lead-in", type=float, default=0.5, help="Silence before morse starts in seconds (default: 0.5)")
parser.add_argument("--volume", type=int, default=None, metavar="0-100", help="Speaker volume to set before transmitting")
args = parser.parse_args()
sequence = text_to_sequence(args.message, args.unit)
if not sequence:
print("No encodable characters in message.")
return
morse_str = " / ".join(
" ".join(MORSE[ch] for ch in word.upper() if ch in MORSE)
for word in args.message.split()
)
total = args.lead_in + sum(d for _, d in sequence)
print(f"Message : {args.message}")
print(f"Morse : {morse_str}")
print(f"Duration: {total:.1f}s (unit={args.unit}s)")
with httpx.Client(headers=HEADERS, verify=False) as client:
cameras = client.get(f"{BASE}/cameras").raise_for_status().json()
camera = next(c for c in cameras if CAMERA_NAME in c["name"])
camera_id = camera["id"]
original_led = camera["ledSettings"]["isEnabled"]
print(f"Camera : {camera['name']} ({camera_id})")
if args.volume is not None:
set_speaker_volume(camera_id, args.volume)
session = client.post(f"{BASE}/cameras/{camera_id}/talkback-session").raise_for_status().json()
rate = session["samplingRate"]
print(f"Talkback: {session['url']} ({session['codec']} {rate}Hz)")
pcm = generate_pcm(sequence, rate, args.lead_in)
tmp = tempfile.NamedTemporaryFile(suffix=".pcm", delete=False)
try:
tmp.write(pcm)
tmp.close()
ffmpeg = subprocess.Popen(
[
"ffmpeg", "-loglevel", "error",
"-re",
"-f", "s16le", "-ar", str(rate), "-ac", "1", "-i", tmp.name,
"-acodec", "libopus", "-b:a", "64k",
"-f", "rtp", session["url"],
],
)
stop = threading.Event()
blinker = threading.Thread(
target=led_worker,
args=(camera_id, sequence, args.lead_in, original_led, stop),
daemon=False,
)
print("Transmitting... (Ctrl+C to abort)")
blinker.start()
try:
ffmpeg.wait()
blinker.join()
except KeyboardInterrupt:
print("\nAborted.")
stop.set()
ffmpeg.terminate()
ffmpeg.wait()
blinker.join(timeout=3)
finally:
os.unlink(tmp.name)
if __name__ == "__main__":
main()