← Agentic AI & Automation
Private project · Event & venue capture

Camera System — Planica Zipline & Adventure Parks

A camera system built for two use cases: Planica (Slovenia), the world’s largest ski jump (250 m+ world record), where GoPro Max 360° cameras record zipline descents; and adventure parks, where GoPro Hero 12+ cameras automatically take photos of guests. Everything is centrally managed over MikroTik routers and WireGuard VPN tunnels, with Raspberry Pi on-site to trigger and monitor cameras.

Python Raspberry Pi Bluetooth LE WireGuard MikroTik PowerShell
Planica · venue display (tablica)
Built with agentic AI. Development on this project runs through multi-agent Claude Code workflows — developer → independent-reviewer cycles with evidence gates, batched into unattended runs that execute overnight and resume from committed ledgers. How the agent system works →

What this is for (plain language)

Planica is the hill where ski-jumping world records are set; the zipline runs from the top so visitors can experience the view. The system has to start multiple 360° cameras when guests are about to go — without someone pressing each camera by hand. At adventure parks, the goal is automatic photo capture (e.g. at the end of a run or at a photo point) so guests can buy prints. All of this runs at remote sites, so management (monitoring, file collection, updates) is done over a private VPN so one place can see and control everything.

What these parts do

How it fits together

At Planica, a Raspberry Pi runs a Python script that continuously scans for GoPro Max cameras (by Bluetooth address). When a camera is seen after being absent for a while, or when its signal strength goes from weak to strong (e.g. someone brings it into the start area), the script sends the BLE command to start recording. So staff just power on the cameras and bring them into range; recording starts automatically. A Windows PC (on the same network or over WireGuard) can run a watchdog that pings the Pi and sends an email if the Pi goes offline, so you know if the site has a power or network issue. Footage (e.g. 360° files) is copied or synced to a central location; a PowerShell workflow picks up new files and can drive Adobe Premiere (via ExtendScript) to open the right project and import or process the clip. At adventure parks, Hero 12+ cameras are used with automatic photo capture; the same idea of central management over WireGuard applies so you can manage and retrieve photos from one place.

Planica — Zipline 360° recording

  • Raspberry Pi runs a Python service: Bluetooth scan for known GoPro Max MAC addresses.
  • When a camera (re)appears or signal strength improves, send BLE “start record” (gatttool write to the GoPro characteristic).
  • Multiple cameras supported; failed triggers trigger email alerts after several retries.
  • 360° footage is transferred to a PC; PowerShell watches for new files and can launch Premiere + ExtendScript for project/open by “password” (filename).

Central management & monitoring

  • MikroTik at venue and at centre; WireGuard tunnel between them (UDP, one listen port, peers by public key).
  • Firewall rules on the router allow WireGuard and VPN subnet traffic so the Pi is reachable over the tunnel.
  • Watchdog on a PC pings the Pi periodically; if the Pi stops responding, send an email alert; optional “back online” notification.
  • Same VPN used for SSH, file pull, and any future remote control or config.

Representative code

On the Pi: trigger recording by writing to the GoPro BLE characteristic. On the router: WireGuard interface and firewall so the tunnel works.

Raspberry Pi — Start GoPro recording via BLE

py_new.py – gatttool write to start record
cmd = [
    "timeout", "--foreground", "3",
    "gatttool", "-t", "random", "-b", mac,
    "--char-write-req", "-a", "0x2f", "-n", "03170101"
]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
# ... check output for "Characteristic value was written successfully"
# Then bluetoothctl disconnect so the next camera can be triggered

The script keeps a list of known camera MAC addresses. When the scanner sees a camera (or sees it come back into strong signal range), it runs gatttool to write the start-record command to the GoPro’s BLE characteristic, then disconnects so the next camera can be addressed.

Raspberry Pi — BLE camera discovery loop (ble_scan_async.py)

ble_scan_async.py – discovery + scan loop
devices_to_scan = {
    'ef:b5:3d:11:f4:31': '00000001',
    'd5:ed:26:d6:c2:3b': '00000002',
    'dd:30:f0:c9:83:f0': '00000003'
}

class ScanDelegate(DefaultDelegate):
    def handleDiscovery(self, dev, isNewDev, isNewData):
        if dev.addr in devices_to_scan:
            if dev.addr not in detected_devices:
                detected_devices[dev.addr] = {
                    "name": devices_to_scan[dev.addr],
                    "last_seen": time.time(),
                    "rssi": None,
                    "low_rssi_start": None,
                    "to_connect": None
                }

def blocking_scan():
    scanner = Scanner().withDelegate(ScanDelegate())
    return scanner.scan(2)

async def scan_for_devices(executor):
    while True:
        devices = await asyncio.get_event_loop().run_in_executor(executor, blocking_scan)
        await update_device_list(devices)
        await asyncio.sleep(1)

The async scanner keeps a registry of “known” cameras and continuously updates their RSSI / last_seen timestamps. Downstream tasks can then decide when it’s time to trigger recording and/or run Wi‑Fi recovery steps.

Windows — process exported .360 files (scan_files.ps1)

scan_files.ps1 – watch folder + call final_rpi.ps1
$dir7="C:\rpi_files"

while ($true) {
  $files = Get-ChildItem -File $dir7 |
           Where { $_.Name -like "*.360" } |
           Sort-Object LastWriteTime

  if ($files) {
    Foreach ($file in $files) {
      $password = ($file.Name).Substring(0, ($files[0].Name).IndexOf("."))
      $cam_num  = ($password.substring(0,2)).trimstart('0')

      if ($file -like "p*") {
        .("$dir1\scripts\final_rpi.ps1")
      } else {
        .("$dir1\scripts\final_cepa.ps1")
      }
    }
  }

  Start-Sleep 5
}

PowerShell polls for new camera exports (.360), derives the “password/name” from the filename, and then dot-sources the per-camera processing script (e.g. final_rpi.ps1).

Premiere — relink media + export (final.jsx)

final.jsx – open project, replace clip media, export
user = "VideoEkipa2288"
var file = File("C:\\Users\\"+user+"\\Desktop\\password_premiere.txt")
file.open("r")
var password = file.read()

if (password.charAt(0) === 'p') {
  var projectPath = "C:\\Users\\VideoEkipa2288\\OneDrive\\program\\scripts\\planica.prproj"
  app.openDocument(projectPath)
  app.project = app.projects[0]
}

if (app.project && app.project.name === "planica.prproj") {
  var projectItem = app.project.rootItem

  projectItem.children[5].changeMediaPath(
    "C:\\Users\\"+user+"\\Desktop\\premiere\\"+password+".mp4"
  )

  app.project.sequences[0].setOutPoint(end_extra + start_zamik)
  app.project.sequences[0].exportAsMediaDirect(
    dir1 + "\\final_media\\" + password + ".mp4",
    "C:\\Users\\"+user+"\\OneDrive\\program\\scripts\\CPR1.epr",
    1
  )
}

The ExtendScript reads the “password” from a local text file, opens the correct Premiere project, swaps the sequence’s main media path, adjusts cut points, and exports the final clip.

MikroTik — WireGuard and firewall

WireGuard on MikroTik (simplified)
/interface wireguard
add listen-port=13231 name=wireguard1
/ip address
add address=192.168.77.1/24 interface=wireguard1
/interface wireguard peers
add allowed-address=192.168.77.2/32 interface=wireguard1 public-key="..."

/ip firewall filter
add action=accept chain=input comment="allow WireGuard" dst-port=13231 protocol=udp
add action=accept chain=input comment="allow WireGuard traffic" src-address=192.168.77.0/24

One router has the WireGuard interface and a private subnet (e.g. 192.168.77.0/24); the other site is added as a peer with its public key. Firewall rules allow the WireGuard port and traffic from the VPN subnet so the tunnel is usable for management.

Under the hood — the hard parts

Behind the "cameras start themselves" behavior sits a set of genuinely difficult engineering problems: signal processing on camera telemetry, reverse-engineering an undocumented 360° projection, and making remote embedded hardware field-replaceable.

Gyroscope-driven ride detection

The auto-edit needs the ride's true start and finish inside a long recording. The pipeline extracts the GoPro's GPMF telemetry stream with FFmpeg, computes the angular-velocity magnitude at 800 Hz, and applies dual rolling mean-absolute-change windows (1 s and 5 s) with a reset rule so pre-ride handling never false-triggers — plus per-launch-point rule variants. No manual scrubbing, ever.

start_finish.py – 800 Hz gyro stream
gyro_data['Angular_Velocity_Mag'] = np.linalg.norm(
    gyro_data[['GyroX', 'GyroY', 'GyroZ']], axis=1)

# rolling mean-absolute-change over 1 s and 5 s at 800 Hz
rolling_mac_1s = gyro_data['Angular_Velocity_Mag'].diff().abs() \
                     .rolling(window=int(1 * 800), min_periods=1).mean()
rolling_mac_5s = gyro_data['Angular_Velocity_Mag'].diff().abs() \
                     .rolling(window=int(5 * 800), min_periods=1).mean()

360° reprojection in stock FFmpeg

Rather than build a custom FFmpeg fork, the GoPro MAX EAC→equirectangular projection kernel was ported to NumPy and emitted as raw gray16le remap lookup tables consumed by stock FFmpeg's remap filter — handling the format's overlap/seam geometry. Because GoPro documents neither the orientation-quaternion convention nor FFmpeg's Euler order, three independent calibration scripts recover the exact rotation empirically — one encodes each pixel's 3D direction into an image's RGB and fits the rotation over ~130k pixels with no convention assumptions at all.

convert/run_prod.sh – remap-table reprojection
ffmpeg -y -i INPUT.360 \
 -f rawvideo -pix_fmt gray16le -s 5376x2688 -i map_x.raw \
 -f rawvideo -pix_fmt gray16le -s 5376x2688 -i map_y.raw \
 -filter_complex \
 "[0:0][0:5]vstack=inputs=2[s];[s][1:v][2:v]remap,scale=3840:1920:flags=lanczos[v]" \
 -map "[v]" -map 0:1 -c:v libx264 -crf 20 OUTPUT.mp4

Bluetooth reliability & field-replaceable hardware

  • Radio role separation: a single adapter doing both scanning and GATT writes made every trigger fight the scanner. Now a USB dongle scans (better sensitivity for distant cameras) while the onboard radio does the writes — adapters resolved by sysfs bus type, never by hciN index, because numbering swaps between reboots.
  • BD_ADDR cloning: determined empirically that GoPros check the Bluetooth address they paired with, not any key the host holds — so a replacement Pi presents the original's address at boot and all 14 cameras accept it with zero re-pairing.
  • Zero-touch provisioning: git clone … && bash setup.sh && sudo reboot rebuilds any SD card — idempotent, handles two OS generations, installs the systemd service, a cron auto-updater and narrow sudoers rules.

Operational hardening & human factors

  • Four independent watchdog layers for a remote LTE-only site: a hardware ping watchdog on the router, an LTE-interface bounce script, a nightly scheduled reboot, and a Windows-service ping monitor on the Pi.
  • Coordinate-free GUI automation: the GoPro export app is driven via Windows UI Automation by element name and live bounding rectangle — surviving moved windows, different resolutions and DPI scaling — replacing hardcoded pixel clicks.
  • Redemption codes come from a 29-character alphabet with every visually confusable glyph removed (no O/0, I/1, J, L, Q), collision-checked against the full history — the code is printed, spoken and typed by members of the public.