This commit is contained in:
parent
ab3a76cd0f
commit
56d8fb4d56
85 changed files with 8879 additions and 0 deletions
102
clio-pi/README.md
Normal file
102
clio-pi/README.md
Normal 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`.
|
||||
352
clio-pi/bin/gen_recording_status.py
Executable file
352
clio-pi/bin/gen_recording_status.py
Executable 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="Don’t 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
31
clio-pi/bin/record.sh
Executable 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
238
clio-pi/setup.sh
Executable 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 ""
|
||||
11
clio-pi/systemd/record.service
Normal file
11
clio-pi/systemd/record.service
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue