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
- GoPro Max — 360° camera used for zipline runs at Planica. It can be told to start or stop recording over Bluetooth Low Energy (BLE), so a small computer (Raspberry Pi) can trigger several cameras at once when they are in range.
- GoPro Hero 12+ — Standard action cameras used at adventure parks for automatic photo capture (e.g. on a timer or trigger). Same idea: remote control so no one has to be at each camera.
- Raspberry Pi — Runs on-site (e.g. at Planica). It scans for known GoPro Bluetooth addresses; when a camera appears or comes back into good range, it sends the BLE “start recording” command. A systemd service keeps this running and restarts it if it crashes. Optionally it can receive files from the cameras or forward status.
- MikroTik routers — Used at the venue and at the central site. They provide Wi‑Fi/local network and, importantly, run WireGuard so the venue network is connected to the central management network over an encrypted tunnel. That way you can SSH into the Pi, pull footage, or run scripts as if you were on the same LAN.
- WireGuard — A lightweight VPN. On the MikroTik you create a WireGuard interface, add a peer (the other site’s public key and allowed IP), and open the firewall for the WireGuard port and for traffic from the VPN subnet. Once that’s up, the Pi (or a PC at the venue) has a stable private IP over the tunnel and can be managed from the centre.
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” (
gatttoolwrite 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
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)
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)
$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)
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
/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.
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.
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
hciNindex, 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 rebootrebuilds 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.