Remove redundant cursor styling
Some checks failed
Deploy API / deploy (push) Failing after 1s
Deploy UI / deploy (push) Successful in 1s

This commit is contained in:
Your Name 2026-05-19 19:48:13 -04:00
parent 080b4bdaf1
commit b24dd72ae0
93 changed files with 9058 additions and 1 deletions

102
clio-pi/README.md Normal file
View file

@ -0,0 +1,102 @@
# Clio Pi
Raspberry Pi recording station for the Clio audio system.
## Features
- Continuous audio recording with ffmpeg (opus codec, 2hr segments)
- Systemd service for auto-start on boot
- HTML status page showing recording coverage
- NTP time sync via chrony
- SSH key-only access (hardened)
## Quick Setup
1. Flash Raspberry Pi OS Lite to SD card using Raspberry Pi Imager
2. Configure in Imager: hostname, user/pass, WiFi, enable SSH
3. Boot and SSH into Pi
4. Copy this directory to the Pi:
```bash
rsync -avz /work/projects/clio/clio-pi/ pi@<pi-ip>:~/clio-pi/
```
5. Run setup:
```bash
ssh pi@<pi-ip>
cd ~/clio-pi
sudo ./setup.sh
```
6. Add your SSH key before rebooting:
```bash
ssh-copy-id pi@<pi-ip>
```
7. Start recording:
```bash
sudo systemctl start record.service
```
## Files
```
clio-pi/
├── setup.sh # Main setup script (run with sudo)
├── bin/
│ ├── record.sh # Recording loop (ffmpeg)
│ └── gen_recording_status.py # Status page generator
├── systemd/
│ └── record.service # Systemd service unit
└── README.md
```
## Configuration
Edit `~/bin/record.sh` environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `AUDIO_DEVICE` | `hw:1` | ALSA device (run `arecord -l` to list) |
| `SEGMENT_SECONDS` | `7200` | Segment length (2 hours) |
| `RECORDINGS_DIR` | `~/recordings` | Output directory |
| `SOURCE_NAME` | `stationary` | Filename prefix |
## Services
| Service | Description |
|---------|-------------|
| `record.service` | Recording daemon |
| `chrony` | NTP time sync |
| `nginx` | Status page web server |
## Commands
```bash
# Recording service
sudo systemctl start record.service
sudo systemctl stop record.service
sudo systemctl status record.service
journalctl -u record.service -f
# Check time sync
chronyc tracking
timedatectl
# View status page
curl http://localhost/
```
## Syncing Recordings to Server
TODO: Add rsync/sync script to push recordings to server (10.0.0.45)
## Audio Device Setup
List available devices:
```bash
arecord -l
```
Example output:
```
card 1: Device [USB Audio Device], device 0: USB Audio [USB Audio]
```
This would be `hw:1,0` or just `hw:1`.

View file

@ -0,0 +1,352 @@
#!/usr/bin/env python3
import argparse
import datetime as dt
import html
import os
import re
import shutil
import statistics
from pathlib import Path
from typing import List, Tuple, Optional
FNAME_RE = re.compile(r"^stationary-(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.opus$")
def parse_start(path: Path, tz) -> Optional[dt.datetime]:
m = FNAME_RE.match(path.name)
if not m:
return None
date_str, time_str = m.groups()
start = dt.datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H-%M-%S")
return start.replace(tzinfo=tz)
def get_duration_seconds(path: Path) -> Optional[float]:
try:
from mutagen.oggopus import OggOpus # optional
audio = OggOpus(path)
return float(getattr(audio.info, "length", 0.0)) or None
except Exception:
return None
def human_delta(delta: dt.timedelta) -> str:
secs = int(abs(delta).total_seconds())
d, rem = divmod(secs, 86400)
h, rem = divmod(rem, 3600)
m, s = divmod(rem, 60)
parts = []
if d: parts.append(f"{d}d")
if h: parts.append(f"{h}h")
if m: parts.append(f"{m}m")
if not parts: parts.append(f"{s}s")
return " ".join(parts)
def merge_intervals(intervals: List[Tuple[dt.datetime, dt.datetime]]) -> List[Tuple[dt.datetime, dt.datetime]]:
if not intervals: return []
intervals.sort(key=lambda x: x[0])
merged = [intervals[0]]
for s, e in intervals[1:]:
ls, le = merged[-1]
if s <= le:
merged[-1] = (ls, max(le, e))
else:
merged.append((s, e))
return merged
def day_bounds(d: dt.date, tz):
start = dt.datetime(d.year, d.month, d.day, tzinfo=tz)
return start, start + dt.timedelta(days=1)
def intersect(a: Tuple[dt.datetime, dt.datetime], b: Tuple[dt.datetime, dt.datetime]) -> Optional[Tuple[dt.datetime, dt.datetime]]:
s1, e1 = a; s2, e2 = b
s = max(s1, s2); e = min(e1, e2)
return (s, e) if s < e else None
def pct(x, total):
return 0 if total <= 0 else max(0, min(100, (x/total)*100))
def fmt_ampm(t: dt.datetime) -> str:
s = t.strftime("%I:%M%p") # 01:05PM
return s.lstrip("0")
def build(args):
tz = dt.datetime.now().astimezone().tzinfo
rec_dir = Path(args.dir).expanduser().resolve()
files = [p for p in rec_dir.glob("*.opus") if p.is_file()]
files.sort()
# Gather metadata
items = [] # (path, start_dt, size_bytes, duration_opt)
for p in files:
s = parse_start(p, tz)
if not s:
continue
dur = get_duration_seconds(p) if args.read_durations else None
try:
size = p.stat().st_size
except Exception:
size = 0
items.append((p, s, size, dur))
items.sort(key=lambda x: x[1])
starts = [s for _, s, _, _ in items]
deltas = [(b - a).total_seconds() for a, b in zip(starts, starts[1:])]
# Duration policy (defaults: NO inference, assume 2h)
if args.infer:
sane = [d for d in deltas if args.infer_min_sec <= d <= args.infer_max_sec]
inferred_default = (statistics.median(sane) if sane else args.assume_duration_sec or args.infer_default_sec)
inferred_default = max(args.infer_min_sec, min(args.infer_max_sec, inferred_default))
else:
inferred_default = args.assume_duration_sec or args.infer_default_sec
# Build intervals
intervals: List[Tuple[dt.datetime, dt.datetime]] = []
for idx, (p, s, _size, dur) in enumerate(items):
if dur is None or dur <= 0:
dur_use = float(inferred_default)
else:
dur_use = dur
if idx < len(items)-1 and args.guard_gap_sec > 0:
to_next = (items[idx+1][1] - s).total_seconds()
dur_use = min(dur_use, max(1.0, to_next - args.guard_gap_sec))
e = s + dt.timedelta(seconds=max(1.0, dur_use))
intervals.append((s, e))
gen_time = dt.datetime.now(tz)
last_start = starts[-1] if starts else None
# FS usage for recordings partition
try:
total_b, used_b, free_b = shutil.disk_usage(str(rec_dir))
except Exception:
total_b = used_b = free_b = 0
# Window
today = gen_time.date()
start_date = today - dt.timedelta(days=args.days - 1)
big_gap = dt.timedelta(minutes=args.gap_yellow_min)
green_gap = dt.timedelta(minutes=args.gap_green_min)
rows = []
window_total_bytes = 0
for i in range(args.days):
d = start_date + dt.timedelta(days=i)
d_start, d_end = day_bounds(d, tz)
# Per-day file count and bytes (count files that START this day)
day_files = [(p, s, size) for (p, s, size, _du) in [(p, s, size, du) for (p, s, size, du) in items] if d_start <= s < d_end]
day_bytes = sum(size for (_p, _s, size) in day_files)
window_total_bytes += day_bytes
# Intervals overlapping this day
day_intervals = []
for (s, e) in intervals:
inter = intersect((s, e), (d_start, d_end))
if inter:
day_intervals.append(inter)
merged = merge_intervals(day_intervals)
covered = sum((e - s).total_seconds() for s, e in merged)
day_seconds = 86400.0
coverage_pct = pct(covered, day_seconds)
# Max gap includes edges
if not merged:
max_gap = dt.timedelta(days=1)
else:
gaps = [merged[0][0] - d_start]
gaps += [b[0] - a[1] for a, b in zip(merged, merged[1:])]
gaps.append(d_end - merged[-1][1])
max_gap = max(gaps) if gaps else dt.timedelta(0)
# Status
count = len(day_files)
if count == 0:
status = "red"
elif max_gap <= green_gap and coverage_pct >= args.green_cover_pct:
status = "green"
elif max_gap >= big_gap or coverage_pct < args.yellow_cover_pct:
status = "yellow"
else:
status = "yellow"
# Mini timeline segments
segs = []
for s, e in merged:
left = pct((s - d_start).total_seconds(), day_seconds)
width = pct((e - s).total_seconds(), day_seconds)
if width > 0:
segs.append((left, width))
# Details list: show ALL spans; AM/PM times
details_txt = ", ".join(f"{fmt_ampm(s)}{fmt_ampm(e)}" for s, e in merged)
rows.append({
"date": d.isoformat(),
"count": count,
"bytes": day_bytes,
"max_gap": max_gap,
"coverage_pct": coverage_pct,
"status": status,
"segments": segs,
"details": details_txt or "",
})
# HTML
def hhmm(td: dt.timedelta) -> str:
total = int(td.total_seconds())
h = total // 3600
m = (total % 3600) // 60
return f"{h:02d}:{m:02d}"
def fmt_bytes(n: int) -> str:
units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB")
x = float(n)
for u in units:
if x < 1024.0 or u == units[-1]:
return f"{int(x)} {u}" if u == "B" else f"{x:.1f} {u}"
x /= 1024.0
title = args.title
last_str = ""
if last_start:
last_str = f"{last_start.strftime('%Y-%m-%d %I:%M:%S %p %Z')} ({human_delta(gen_time - last_start)} ago)"
# FS usage strings
total_str = fmt_bytes(total_b)
used_str = fmt_bytes(used_b)
free_str = fmt_bytes(free_b)
window_bytes_str = fmt_bytes(window_total_bytes)
html_out = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="300">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<style>
:root {{
--green:#30a46c; --yellow:#f5d90a; --red:#e5484d;
--bg-green:#e9f7f0; --bg-yellow:#fff8cc; --bg-red:#fdeaea;
}}
body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 1.5rem; }}
h1 {{ margin: 0 0 .4rem 0; }}
.muted {{ color: #666; font-size: .9rem; }}
table {{ border-collapse: collapse; width: 100%; max-width: 1200px; }}
th, td {{ padding: .5rem .6rem; border-bottom: 1px solid #eee; text-align: left; vertical-align: middle; }}
tr:hover td {{ background: #fafafa; }}
.right {{ text-align: right; }}
.row-green td {{ background: var(--bg-green); }}
.row-yellow td {{ background: var(--bg-yellow); }}
.row-red td {{ background: var(--bg-red); }}
.status-dot {{ display:inline-block;width:10px;height:10px;border-radius:50%;vertical-align:middle;margin-right:.4rem;}}
.green {{ background: var(--green); }}
.yellow {{ background: var(--yellow); }}
.red {{ background: var(--red); }}
.bar {{ position: relative; height: 12px; background: #eee; border-radius: 6px; overflow: hidden; }}
.bar .seg {{ position: absolute; top:0; bottom:0; background: var(--green); opacity:.9; }}
.cover-label {{ font-size:.85rem; white-space:nowrap; }}
.nowrap {{ white-space:nowrap; }}
.hdr {{ margin-top:.6rem; margin-bottom:.8rem; }}
.kvs span {{ margin-right: 1rem; }}
</style>
</head>
<body>
<h1>{html.escape(title)}</h1>
<div class="muted">Generated at {gen_time.strftime('%Y-%m-%d %I:%M:%S %p %Z')}</div>
<div class="hdr kvs muted">
<span><strong>Last recording started:</strong> {html.escape(last_str)}</span>
<span><strong>Window:</strong> {args.days} days</span>
<span><strong>Dir:</strong> {html.escape(str(rec_dir))}</span>
</div>
<div class="hdr kvs">
<span><strong>Data in window:</strong> {window_bytes_str}</span>
<span><strong>Filesystem usage (recordings partition):</strong> used {used_str} free {free_str} total {total_str}</span>
</div>
<table>
<thead>
<tr>
<th>Date</th>
<th class="right"># Recs</th>
<th class="right">Data</th>
<th class="right">Max gap</th>
<th>Coverage</th>
<th>Spans</th>
<th>Status</th>
</tr>
</thead>
<tbody>
"""
for r in reversed(rows):
cls = f"row-{r['status']}"
cov = int(round(r["coverage_pct"]))
seg_divs = "".join(
f'<div class="seg" style="left:{left:.3f}%;width:{width:.3f}%"></div>'
for left, width in r["segments"]
)
data_str = fmt_bytes(r["bytes"])
html_out += f""" <tr class="{cls}">
<td class="nowrap">{r['date']}</td>
<td class="right">{r['count']}</td>
<td class="right">{data_str}</td>
<td class="right">{hhmm(r['max_gap'])}</td>
<td>
<div class="bar" title="{cov}% covered">{seg_divs}</div>
<div class="cover-label">{cov}%</div>
</td>
<td><div class="muted">{html.escape(r['details'])}</div></td>
<td><span class="status-dot {r['status']}"></span>{r['status']}</td>
</tr>
"""
html_out += """ </tbody>
</table>
<p class="muted">Legend: <span class="status-dot green"></span> green = nearly continuous <span class="status-dot yellow"></span> yellow = gaps or low coverage <span class="status-dot red"></span> red = no recordings</p>
</body>
</html>
"""
out_path = Path(args.out).resolve()
out_path.parent.mkdir(parents=True, exist_ok=True)
tmp = out_path.with_suffix(out_path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
f.write(html_out)
os.replace(tmp, out_path)
def main():
p = argparse.ArgumentParser(description="Generate recordings status HTML")
p.add_argument("--dir", default=str(Path.home() / "recordings"),
help="Directory containing .opus files (default: ~/recordings)")
p.add_argument("--out", default="/var/www/html/index.html",
help="HTML output path (default: /var/www/html/index.html)")
# Defaults tuned so cron needs no flags:
p.add_argument("--days", type=int, default=365, help="How many days to show (default: 365)")
p.add_argument("--gap-yellow-min", type=int, default=120, help="Gap (minutes) that marks a day as yellow (default: 120)")
p.add_argument("--gap-green-min", type=int, default=15, help="Max gap (minutes) to still be green (default: 15)")
p.add_argument("--read-durations", action="store_true",
help="Use mutagen to read exact file durations (optional; default off).")
p.add_argument("--green-cover-pct", type=float, default=90.0,
help="Min coverage%% for green (default: 90)")
p.add_argument("--yellow-cover-pct", type=float, default=10.0,
help="Coverage%% below which day is yellow (default: 10)")
# Duration policy: default is NO inference, assume 2h
p.add_argument("--assume-duration-sec", type=int, default=7200,
help="Assumed duration (s) when real duration is unavailable. Default: 7200 (2h).")
p.add_argument("--infer", action="store_true",
help="Enable inferring duration from median start deltas (disabled by default).")
p.add_argument("--infer-min-sec", type=int, default=60,
help="Ignore deltas smaller than this when inferring (default: 60s)")
p.add_argument("--infer-max-sec", type=int, default=6*3600,
help="Ignore deltas larger than this when inferring (default: 6h)")
p.add_argument("--infer-default-sec", type=int, default=20*60,
help="Fallback segment length if nothing else is available (default: 1200s)")
p.add_argument("--guard-gap-sec", type=int, default=0,
help="Dont let a segment extend within this many seconds of next start (default: 0)")
p.add_argument("--title", default="Portable Recorder Status", help="Page title")
args = p.parse_args()
build(args)
if __name__ == "__main__":
main()

31
clio-pi/bin/record.sh Executable file
View file

@ -0,0 +1,31 @@
#!/bin/bash
set -euo pipefail
# Configuration
AUDIO_DEVICE="${AUDIO_DEVICE:-hw:1}" # ALSA device (run 'arecord -l' to list)
SEGMENT_SECONDS="${SEGMENT_SECONDS:-7200}" # 2 hours per file
RECORDINGS_DIR="${RECORDINGS_DIR:-$HOME/recordings}"
SOURCE_NAME="${SOURCE_NAME:-stationary}" # Prefix for filenames
mkdir -p "${RECORDINGS_DIR}"
echo "Starting recording..."
echo " Device: ${AUDIO_DEVICE}"
echo " Segment: ${SEGMENT_SECONDS}s"
echo " Output: ${RECORDINGS_DIR}/${SOURCE_NAME}-*.opus"
while true; do
ffmpeg -f alsa -ac 1 -i "${AUDIO_DEVICE}" \
-c:a libopus \
-b:a 32k \
-f segment \
-segment_time "${SEGMENT_SECONDS}" \
-strftime 1 \
-reset_timestamps 1 \
"${RECORDINGS_DIR}/${SOURCE_NAME}-%Y-%m-%d_%H-%M-%S.opus" \
2>&1 | while read line; do echo "[ffmpeg] $line"; done
echo "Recording stopped, restarting in 5s..."
sleep 5
done

238
clio-pi/setup.sh Executable file
View file

@ -0,0 +1,238 @@
#!/bin/bash
set -euo pipefail
# Clio Pi Setup Script
# Run this after a fresh Raspberry Pi OS install
#
# Prerequisites:
# 1. Flash Raspberry Pi OS Lite to SD card
# 2. Use Raspberry Pi Imager to pre-configure:
# - Hostname (e.g., clio-pi)
# - Username/password
# - WiFi credentials
# - Enable SSH
# 3. Boot Pi and SSH in
# 4. Copy this repo to /home/pi/clio-pi
# 5. Run: sudo ./setup.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PI_USER="${PI_USER:-pi}"
PI_HOME="/home/${PI_USER}"
echo "=== Clio Pi Setup ==="
echo "User: ${PI_USER}"
echo "Home: ${PI_HOME}"
echo ""
# ============================================================
# 1. System updates
# ============================================================
echo ">>> Updating system packages..."
apt-get update
apt-get upgrade -y
# ============================================================
# 2. Install required packages
# ============================================================
echo ">>> Installing required packages..."
apt-get install -y \
ffmpeg \
python3 \
python3-pip \
nginx-light \
chrony \
rsync
# Optional: for reading audio durations
pip3 install --break-system-packages mutagen || true
# ============================================================
# 3. Configure NTP/time sync (chrony)
# ============================================================
echo ">>> Configuring time sync (chrony)..."
cat > /etc/chrony/chrony.conf << 'EOF'
# Use default NTP pools
pool 2.debian.pool.ntp.org iburst
# Allow the system clock to be stepped in the first 3 updates
# if its offset is larger than 1 second
makestep 1.0 3
# Enable kernel synchronization of the real-time clock (RTC)
rtcsync
# Serve time to the local subnet (optional)
#allow 10.0.0.0/24
# Log tracking, measurements, statistics
logdir /var/log/chrony
EOF
systemctl enable chrony
systemctl restart chrony
# Set timezone (adjust as needed)
timedatectl set-timezone America/New_York
echo ">>> Time sync configured. Current time:"
timedatectl
# ============================================================
# 4. Configure SSH key-only access
# ============================================================
echo ">>> Configuring SSH for key-only access..."
# Ensure .ssh directory exists
mkdir -p "${PI_HOME}/.ssh"
chmod 700 "${PI_HOME}/.ssh"
touch "${PI_HOME}/.ssh/authorized_keys"
chmod 600 "${PI_HOME}/.ssh/authorized_keys"
chown -R "${PI_USER}:${PI_USER}" "${PI_HOME}/.ssh"
# Harden sshd_config
SSHD_CONFIG="/etc/ssh/sshd_config"
# Backup original
cp "${SSHD_CONFIG}" "${SSHD_CONFIG}.bak"
# Disable password authentication
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' "${SSHD_CONFIG}"
sed -i 's/^#*ChallengeResponseAuthentication.*/ChallengeResponseAuthentication no/' "${SSHD_CONFIG}"
sed -i 's/^#*UsePAM.*/UsePAM no/' "${SSHD_CONFIG}"
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' "${SSHD_CONFIG}"
# Ensure pubkey auth is enabled
grep -q "^PubkeyAuthentication" "${SSHD_CONFIG}" || echo "PubkeyAuthentication yes" >> "${SSHD_CONFIG}"
echo ""
echo "!!! IMPORTANT !!!"
echo "Before rebooting, add your SSH public key to:"
echo " ${PI_HOME}/.ssh/authorized_keys"
echo ""
echo "From your local machine, run:"
echo " ssh-copy-id ${PI_USER}@<pi-ip-address>"
echo ""
echo "Or manually append your ~/.ssh/id_ed25519.pub (or id_rsa.pub)"
echo ""
# Don't restart SSH yet - user needs to add their key first
# ============================================================
# 5. Set up recording directories
# ============================================================
echo ">>> Setting up recording directories..."
mkdir -p "${PI_HOME}/recordings"
mkdir -p "${PI_HOME}/bin"
chown -R "${PI_USER}:${PI_USER}" "${PI_HOME}/recordings"
chown -R "${PI_USER}:${PI_USER}" "${PI_HOME}/bin"
# ============================================================
# 6. Install recording scripts
# ============================================================
echo ">>> Installing recording scripts..."
cp "${SCRIPT_DIR}/bin/record.sh" "${PI_HOME}/bin/"
cp "${SCRIPT_DIR}/bin/gen_recording_status.py" "${PI_HOME}/bin/"
chmod +x "${PI_HOME}/bin/record.sh"
chmod +x "${PI_HOME}/bin/gen_recording_status.py"
chown -R "${PI_USER}:${PI_USER}" "${PI_HOME}/bin"
# ============================================================
# 7. Install systemd service
# ============================================================
echo ">>> Installing systemd service..."
cp "${SCRIPT_DIR}/systemd/record.service" /etc/systemd/system/
sed -i "s|/home/pi|${PI_HOME}|g" /etc/systemd/system/record.service
sed -i "s|User=pi|User=${PI_USER}|g" /etc/systemd/system/record.service
systemctl daemon-reload
systemctl enable record.service
# Don't start yet - user may need to configure audio device
# ============================================================
# 8. Set up cron for status page
# ============================================================
echo ">>> Setting up cron job for status page..."
CRON_LINE="*/10 * * * * python3 ${PI_HOME}/bin/gen_recording_status.py --dir ${PI_HOME}/recordings"
# Install cron job for pi user
(crontab -u "${PI_USER}" -l 2>/dev/null | grep -v gen_recording_status; echo "${CRON_LINE}") | crontab -u "${PI_USER}" -
# ============================================================
# 9. Configure nginx for status page
# ============================================================
echo ">>> Configuring nginx for status page..."
cat > /etc/nginx/sites-available/default << 'EOF'
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
index index.html;
server_name _;
location / {
try_files $uri $uri/ =404;
}
}
EOF
# Ensure www-data can read the status file
mkdir -p /var/www/html
chown -R www-data:www-data /var/www/html
# Allow pi user to write to /var/www/html
usermod -a -G www-data "${PI_USER}" || true
chmod 775 /var/www/html
systemctl enable nginx
systemctl restart nginx
# Generate initial status page
sudo -u "${PI_USER}" python3 "${PI_HOME}/bin/gen_recording_status.py" --dir "${PI_HOME}/recordings" || true
# ============================================================
# 10. Print audio device info
# ============================================================
echo ""
echo "=== Audio Devices ==="
arecord -l || echo "(no audio devices found yet)"
echo ""
echo "The recording script uses hw:1 by default."
echo "Edit ${PI_HOME}/bin/record.sh if your device is different."
# ============================================================
# Summary
# ============================================================
echo ""
echo "============================================================"
echo "=== Setup Complete ==="
echo "============================================================"
echo ""
echo "Next steps:"
echo ""
echo "1. Add your SSH public key:"
echo " From your computer: ssh-copy-id ${PI_USER}@$(hostname -I | awk '{print $1}')"
echo ""
echo "2. Test SSH key login works, then restart SSH:"
echo " sudo systemctl restart sshd"
echo ""
echo "3. Check/configure audio device:"
echo " arecord -l"
echo " Edit ~/bin/record.sh if needed (currently uses hw:1)"
echo ""
echo "4. Start recording service:"
echo " sudo systemctl start record.service"
echo " sudo systemctl status record.service"
echo ""
echo "5. View status page:"
echo " http://$(hostname -I | awk '{print $1}')/"
echo ""
echo "Services installed:"
echo " - chrony (NTP time sync) - RUNNING"
echo " - nginx (status page) - RUNNING"
echo " - record.service - ENABLED (not started)"
echo ""
echo "Cron job installed:"
echo " - gen_recording_status.py runs every 10 minutes"
echo ""

View file

@ -0,0 +1,11 @@
[Unit]
Description=Record
[Service]
ExecStart=/home/pi/bin/record.sh
User=pi
Type=simple
Restart=on-failure
[Install]
WantedBy=multi-user.target