Skip to content

About

Native GTK4 desktop client for Synology Surveillance Station on Linux and BSD

Topics

Resources

Stars

15 stars

Watchers

2 watching

Forks

Repository files navigation

Surveillance Station Client — native GTK4 desktop client for Synology Surveillance Station on Linux and BSD, showing the Live View grid

Lint & Type Check Latest Release Python 3.11+ GTK4 License

No browser needed. Connect directly to your Synology NAS and get live camera feeds, recording playback, PTZ control, snapshots, event alerts, and home mode management — all from a lightweight native desktop application.

Features

Live View

Real-time camera streams in 1×1, 2×2, 3×3, or 4×4 grid layouts, selected from the grid button in the header bar. Each layout keeps its own camera arrangement. Clear a single slot from the camera sidebar or the whole layout from the same grid menu, with a confirmation prompt. Scroll to zoom in on a slot (centered on the cursor) and click-and-drag to pan; on a dewarped fisheye camera they steer the view instead (see below); zoom resets when switching layouts or leaving the page.

Right-click menu of a Live View slot

A slot keeps its stream when a layout switch leaves its camera in the same position, so a single feed that has drifted behind or stopped moving is restarted with Reload in the slot's right-click menu, without disturbing the other slots; a slot showing recorded video reloads at the same moment rather than returning to live. The reload button in the header bar does the same for every slot in the layout, one after another rather than all in the same instant.

Streams are muted by default; hover a slot to reveal a toolbar with mute/volume (remembered per camera) and a quick Snapshot button. Audio reaches the player over the RTSP-family protocols (rtsp, rtsp_over_http, multicast, direct), or over the default auto/websocket protocol when the camera's audio codec is PCMU or AAC (muxed in via ffmpeg); otherwise the mute button stays greyed out until the protocol is changed (or DSM reports a different codec) by right-clicking the camera in the sidebar. A camera with no audio track gets no mute button at all. A muxed camera that stops sending audio for three seconds while its video keeps arriving keeps playing without sound until its stream is restarted, rather than holding up its own video; its volume control greys out as soon as that happens. In History mode a gap is filled with silence instead, since DSM sends no audio at some playback speeds or in reverse. Sound returns automatically when DSM resumes sending audio at supported playback speeds. Cameras with a speaker also get a push-to-talk microphone button — tap to start talking, tap again to stop. Hardware-accelerated rendering via mpv + OpenGL. Works on X11 and Wayland. While a slot plays recorded video from close to the live edge, the client also re-asks DSM every few seconds how far that recording now runs, since one still being written grows after it was first looked up. The client sends a periodic keepalive to hold each WebSocket session open; if a session is ever interrupted anyway (a network blip, a NAS restart), it reconnects on the same pipe transparently, with no visible interruption. A camera the server reports as disabled or disconnected shows an "offline" placeholder instead of freezing, and a WebSocket or RTSP stream that stops responding mid-session shows "stream lost" ("attempting reconnect" while retrying) — both recover automatically and restore the real feed as soon as the camera is reachable again, with no action needed, and a slot that was showing recorded video comes back to the same moment rather than jumping to live.

POS receipts from Surveillance Station Transactions appear over the camera each POS device is paired with: every new receipt is drawn at the top-left of every Live View slot showing that camera, live only, and disappears after a set time. Settings → POS receipt overlay turns it on or off and sets the text size, the maximum lines (a longer receipt keeps its header and total and folds the items between) and how long a receipt stays. While no paired camera is on screen, only the pairing is read, every five minutes, and the overlay switches itself off when the NAS has no Transactions add-on or the account lacks the Transactions privilege.

A fisheye camera can be shown dewarped: pick Fisheye dewarp in its Camera Settings, and Fisheye mount for how it hangs. On a ceiling, looking straight down, Panorama unrolls the circle into two stacked 180° strips with the rim on top, so people stand upright and the slot keeps the camera's shape, and dragging sideways turns it; on a wall, looking straight out, it is one strip as wide as the lens sees with the horizon across the middle, which does not move. The scroll wheel does nothing on a panorama. Virtual PTZ shows a flat view you look around in by dragging and zoom with the scroll wheel, turning all the way round on a ceiling and as far as the lens sees on a wall, but never past straight up or down. Both apply to live and recorded video in Live View, where the view is kept across a Reload and resets like the zoom does, and to the playback window that Recordings, Events and Time Lapse open a recording in. It runs on the GPU after decoding, so hardware decoding stays on, and saved snapshots keep the original fisheye picture; a graphics driver that cannot run it shows the plain picture instead. A lens that is not a centred full circle of 180° can be fitted with Calibrate… next to Fisheye lens: it shows the camera's current picture with the lens circle drawn over it, to be moved and sized onto the edge of the image, and takes the lens's field of view from its specifications. The result is saved with the camera's other settings on Apply, as camera_fisheye_lens in config.toml (see below).

Timeline

A shared timeline strip below the video grid, toggled from the header bar (persists across restarts). Scroll or use the zoom buttons to zoom the time scale (centered on the cursor), click-and-drag to pan; a thin blue border marks which slot's camera the timeline is currently tracking — it follows the last-clicked slot, defaults to the upper-left slot on layout load, and fades after 10 seconds of no activity anywhere in the window.

Timeline following the live stream

Hovering the ruler shows a small preview thumbnail of that camera at the hovered time, with its date/time overlaid.

Timeline in History mode, with speed, step and Live controls

In History mode, a date/time bubble follows the marker as it moves. Below the ruler, a recording-presence bar shows two rows: where the tracked slot's own camera has a recording, and where any camera in the current layout does. Clicking a point on the ruler switches every slot in the current layout into History mode, each playing its own camera's recorded video from that time; a Live button returns every slot to the real-time stream. Picking a different camera into a slot already in History mode keeps playing recorded video, for the newly picked camera, at the same point in time, rather than dropping back to live; switching grid layout always returns to live first. Pan/tilt/zoom, focus, preset, patrol, and push-to-talk are unavailable while a slot shows recorded video (mute/volume and Snapshot remain available).

Jump ±10s buttons seek every slot in the current layout the same way clicking the ruler does, coalescing a burst of rapid clicks into a single request rather than firing one per click. A Pause button freezes every active slot in place — a live slot just freezes locally without leaving Live mode, while a History slot asks DSM to actually stop sending, so the gap behind wall clock grows for as long as it stays paused; resuming a History slot paused for under 10 seconds lands at least 10 seconds behind live rather than right at the edge. A Pause ends by itself on pressing Live, on leaving the page and coming back, and on switching layout: a page round-trip starts every slot afresh, while the other two resume whatever they leave running and restart only what they replace. A stream that starts while a Pause is on joins it, so a camera picked into a paused layout is frozen with the rest.

Timeline playback speed and direction

A playback-speed dropdown (History mode only) offers 1/8x through 100x plus a Fwd/Rev direction toggle, applied to every active History slot at once; it resets to 1x/Fwd whenever a slot returns to Live or the layout switches. The higher speeds are greyed out on the larger layouts, since DSM really does send that many more frames per second and every slot has to decode them: by default, 1×1 offers the full range, 2×2 stops at 16x, 3×3 at 8x, and 4×4 at 4x — adjustable from the Settings page, with 1x always on offer whatever the budget. Real motion/alarm events are overlaid on the same two presence rows as orange markers. Previous event/Next event jump to the nearest one on either side of the current position, across every camera in the layout; Previous stays available in Live mode (dropping into History first, like Back 10s), while Next is History-only, like Forward 10s. Shortly after landing on an event, Previous goes on to the one before it; once you have watched it for a while, Previous returns to its start instead. How long counts as "shortly" grows with how wide the timeline is zoomed (10 to 30 seconds) and with the playback speed. Next skips only what a jump has just landed on. Playing in reverse it is all mirrored: events are entered at their end, and the grace period applies to Next. Events already loaded for the timeline are used straight away; past those, the search widens step by step (an hour, a day, a week) while the button pulses. Events within 10 seconds of wall clock are skipped, since a seek that close would just be clamped back to the live edge anyway.

Timeline date and time picker

A calendar button opens a date/time picker, its own days marked and any day without a recording (across the current layout) refused, and its Jump button greyed out until the exact date/time selected falls within a real recording; jumping there re-centers the timeline at its default zoom, while Back/Forward 10s and Previous/Next event instead just pan (at whatever zoom is already set) if their own target would otherwise land off-screen.

Timeline event type filter

A Filter events button narrows the presence bar's event markers and Previous/Next event navigation down to chosen event types (a multi-select; on Surveillance Station 9.3 and later it lists every event type the NAS supports and matches Any of them). Before 9.3 it matches Any or All of them, and opening it scans each layout camera's full recording history for the types it has ever produced (a persisted, incrementally-updated cache, so only the first scan and any time elapsed since the last one cost real seconds), showing a per-camera progress checklist meanwhile. While a filter is active, the button's icon is drawn in the same orange as the event markers.

Timeline quick download Timeline custom download range

A Download button opens a popup with a camera picker (only cameras currently assigned to a slot in the layout, since some may be empty, and defaulting to the tracked slot's own camera), above two tabs: Quick Save, whose three one-click buttons immediately prompt for a save location and download around the tracked slot's current position — Download last 1/2/5 min up to it in Live mode, or Download −1/2/5 to +1/2/5 min centered on it in History mode, since both directions are already available once paused on a moment of interest — and Custom Save, with Start/End date-time fields (defaulting to a short clip ending at the same point) for an exact range. Either tab can pick a range that starts before or runs past what has actually been recorded, easy to do close to live; the clip is trimmed to what exists rather than refused. A range with no recording in it at all is refused rather than filled from the nearest recording.

Recordings

Recordings page

Browse, filter by camera, play back with full transport controls (seek, pause, volume, scroll-to-zoom, click-and-drag pan, fisheye dewarp), and download to disk. Quick date presets (Today, Yesterday, Last 24 hrs, Last 7 days) for one-click filtering. Per-event thumbnails and smart detection labels (person, vehicle, animal, etc.) shown for each recording.

Recordings advanced search

Advanced search by camera(s) and custom time range. Reset button clears all filters at once. Active filter summary always visible.

Camera Controls

Slot toolbar with the PTZ pad open

Pan/Tilt, Zoom, Focus, Preset, and Patrol controls for PTZ-capable cameras, in the same per-slot hover toolbar as Live View's audio controls. Picking a patrol asks Surveillance Station to run that saved route; the NAS drives the camera, so it keeps going after you switch cameras or quit. Routes are created and edited in Surveillance Station itself, and there is no stop control, the same as Synology's own clients.

Snapshots

Snapshots page

Browse saved snapshots, filter by camera and time range, view, download, or delete. A camera filter searches at most 5,000 snapshots of the time range, and the page count says so when the range holds more; narrow the range to reach the rest. Take a snapshot straight from a Live View slot's right-click menu, which saves it to the snapshot database and offers a local copy. The full-size viewer supports scroll-to-zoom and click-and-drag panning.

Time Lapse

Time Lapse page

Browse, play back, download, lock/unlock, and delete Smart Time Lapse recordings. Filter by time lapse task.

Events

Events page

Browse real events by their type and time, filtered by event type, camera and time range, from quick filters or advanced search.

Events advanced search

On Surveillance Station 9.3 and later, events and their types come from the Event Center, the same as DSM's own Event Center app: motion, audio, tampering, people/vehicle/animal object detection, intrusion, enter/exit, missing and unattended objects, and more. The type filters then always list every event type the NAS supports, as DSM's own Event Center does, and match Any of the selected ones: one event has exactly one type there. Before 9.3, events are decoded from each camera's own detected categories in the recording-interval bitmask, brand-dependent, and the type filters can also match All of the selected types — see LEGACY_EVENT_BITMASK.md.

Notifications

Notification bell in the header bar with an unread badge and a popover listing Surveillance Station's newest notifications (motion, connection lost/restored, alarm, camera disabled/enabled), polled every 30 seconds. Mark these read marks them read on the NAS: they stay in the list, no longer in bold, and leave the badge count.

Home Mode

Title bar with Home Mode button on the left

Toggle Surveillance Station home mode directly from the header bar.

License Management

Licenses page

View, add, and delete camera licenses. Online and offline activation.

Settings

Settings page

Camera Settings for a WebSocket camera Camera Settings for an RTSP camera

Choose which Surveillance Station stream profile Live View plays (see Live View stream profile below), tune the demuxer cache sizes used for each streaming profile (plain RTSP, WebSocket muxed-audio, and silent WebSocket), the extra buffer added automatically at high History playback speeds, the shared demuxer byte cap, the cap on each player's back buffer, and the speed×slots budget the Live View timeline's History speed dropdown enforces, plus an on-screen readout of cache depth/target/effective speed for diagnosing buffering, the POS receipt overlay (on or off, text size, maximum lines, time on screen), and a switch that decodes events as before Surveillance Station 9.3, for testing. Each setting has its own reset-to-default button, plus one that resets everything on the page at once. Changes apply to the next stream that starts (the on-screen readout takes effect immediately, the receipt overlay with the next receipt, the event decoding switch from the next login, and the speed budget the next time the speed dropdown opens) and persist across restarts.

Session Persistence

Grid layout, active page, camera assignments, sidebar and timeline visibility, per-camera volume, mute, stream protocol and Live View stream profile, and the search filters of each browser page (including time presets) are restored on restart. Critical changes are flushed to disk immediately for crash resilience.

Two-Factor Authentication

MFA/OTP login support. When 2FA is enabled on your Synology account, the client prompts for a 6-digit authenticator code and optionally registers as a trusted device to skip OTP on future logins.

Multi-Profile

Login dialog with a saved profile

Save multiple NAS connection profiles and switch between them from the login screen.

Secure Credentials

Passwords stored in your system keyring (GNOME Keyring, KWallet, macOS Keychain).

Theming

Auto (follow OS), dark, or light theme selectable from the header bar.

About & Updates

About page with an update notice

An About page shows the version, license, and repository links. On login the client checks the GitHub releases page once for a newer version and, if one exists, marks the About entry until you have seen it.

Quick Start (Linux, no install needed)

Download the latest AppImage for your architecture from the Releases page:

chmod +x Surveillance-*-x86_64.AppImage
./Surveillance-*-x86_64.AppImage

Available for x86_64 and aarch64. A new release with AppImages is built automatically every time the version is bumped. They are built on Debian 13 and need glibc 2.39 or newer on the host (Ubuntu 24.04, Debian 13 or later).

The AppImage carries its own Python, GTK and mpv, but uses the host's PipeWire, ALSA and JACK libraries wherever the host has them. Those load plugins from fixed system directories, so a bundled copy picks up the host's plugins and crashes on them. Nothing to install for that, it is what the bundle does on its own. Where it does have to fall back to its own PipeWire, it sends mpv to PulseAudio for the same reason; set SURVEILLANCE_AO to any driver name mpv --ao takes to choose yourself.

Usage

Application startup arguments
surveillance                                  # launch the application
surveillance --debug                          # enable debug logging to stderr
surveillance --log-file                       # log to an auto-named file that will be preserved for sessions that do not exit normally
surveillance --debug --log-file=/tmp/run.log  # log to a file of your own choosing
python -m surveillance                        # run directly from the source tree

Debug logs automatically redact passwords, session tokens, and usernames. A debug run also writes one line a minute recording the app's own memory use, open descriptors and thread count, so a long unattended session leaves a trend behind rather than just whatever state it ended in.

--log-file writes at the same level as stderr (WARNING, or DEBUG with --debug), and additionally captures the traceback of an uncaught exception and the messages GTK and GLib print themselves, neither of which reaches stderr through the log. Given a path, as --log-file=PATH or --log-file PATH, it writes there. Without one, it writes to a fresh, timestamped file under $XDG_STATE_HOME/surveillance-station/logs/ (or ~/.local/state/surveillance-station/logs/ if $XDG_STATE_HOME isn't set) and marks it complete on a clean exit. Files marked complete are deleted by the next run that also passes a bare --log-file. Unmarked files left over from crashes are kept for inspection.

Two environment variables reach the embedded mpv player. SURVEILLANCE_AO picks its audio output driver (see the AppImage note above), and SURVEILLANCE_MPV_OPTS passes any other mpv options, whitespace-separated and written as name=value the way mpv.conf spells them, or a bare name for a flag. They are applied after the client's own options, so they override them:

SURVEILLANCE_MPV_OPTS="hwdec=nvdec hwdec-extra-frames=12" surveillance

Quote a value that contains whitespace as in a shell. A name mpv does not know stops the player from starting, and the log names it. See TROUBLESHOOTING.md for when this is needed.

Login to your Surveillance Station (DSM)

On launch, a login dialog asks for your NAS connection details:

Field Description Default
Profile name Label for this connection (e.g. home-nas) hostname
Host NAS IP address or hostname —
Port DSM port 5001
Use HTTPS Enable HTTPS (recommended) on
Verify SSL Validate the SSL certificate (disable for self-signed) off
Username DSM user with Surveillance Station permissions —
Password DSM password —
Remember credentials Store in system keyring on
Basic usage

Before you connect, a login prompt fills the window and only what works without a NAS stays on screen: the sidebar keeps its Settings row, and the header bar keeps the panel and theme toggles beside a Login button. Everything else appears once you are connected.

After connecting, the camera list appears in the sidebar. Click a camera to start its live stream. Use the navigation buttons at the bottom of the sidebar to switch between Live View, Recordings, Snapshots, Events, Time Lapse, Licenses, Settings, and About; the button for the current page stays highlighted. The header bar shows the current page name and holds the panel toggle on the left, which hides or shows the whole sidebar.

On Live View, the grid button in the header bar selects the layout and can clear the current one, and the reload button beside it restarts every stream in the layout. Click a slot to select it, then click a camera to fill it, or click Empty Slot at the bottom of the camera list to empty it again. Clicking a camera with no slot selected switches to 1×1 and shows only that camera. Right-click a slot for Take Snapshot, Open in 1x1 Layout, Reload, and Clear Slot.

Keyboard shortcuts
Key Action
Ctrl+Q Quit
Configuration

Configuration is stored in TOML format following the XDG base directory specification:

~/.config/surveillance-station/config.toml

Example configuration

[general]
default_profile = "home-nas"
theme = "auto"                  # "auto" (follow OS), "dark", or "light"
sidebar_visible = true          # camera sidebar shown at startup
timeline_visible = true         # Live View timeline strip shown at startup
dismissed_update_version = ""   # release tag whose update notice was dismissed
poll_interval_cameras = 30      # seconds, minimum 5; also how often a lost stream is retried
poll_interval_alerts = 30
poll_interval_homemode = 60
poll_interval_transactions = 5  # POS receipts for the Live View overlay
snapshot_dir = "/home/user/.local/share/surveillance-station/snapshots"  # folder the Save dialog opens in

[session]
grid_layout = "2x2"            # "1x1", "2x2", "3x3", or "4x4"
last_page = "live"             # last active page

# Recording search filters (persisted from last search); the cameras
# picked for them are kept per profile, see below
# search_from_time = "2026-02-01T00:00:00"
# search_to_time = "2026-02-19T23:59:59"
# search_time_preset = "today"  # "today", "yesterday", "last24h", "last7d", "last30d", or ""

[setting_overrides]
# Settings page tuning, numeric values (see the Settings page itself for
# what each one does). Present only for settings actually changed from
# their default; use the page's own reset button to remove one.
# cache_seconds_default = 1.0
# demuxer_max_bytes_mib = 64.0

[setting_overrides_bool]
# Same, for the Settings page's on/off toggles.
# osd_enabled = true
# force_legacy_events = true  # pre-9.3 event decoding, from the next login

[setting_overrides_choice]
# Same, for the Settings page's dropdowns.
# live_view_stream_profile = "low"  # camera (default), high, balanced, low

[profiles.home-nas]
host = "192.168.1.100"
port = 5001
https = true
verify_ssl = false
# Everything keyed by camera ID lives under the profile it belongs to,
# since every NAS numbers its cameras from 1.
# search_camera_ids = [1, 3]   # cameras of the last recording search

[profiles.home-nas.layout_cameras]
# Camera IDs per layout (0 = empty slot).  Each layout remembers its
# own assignment independently.
"1x1" = [1]
"2x2" = [1, 3, 0, 5]
"3x3" = [1, 3, 7, 0, 5, 8, 2, 0, 0]

[profiles.home-nas.camera_overrides]
# Direct RTSP URLs keyed by camera ID.
# Use when Synology's RTSP proxy corrupts a stream (e.g. Reolink Duo 3 PoE h265).
# 5 = "rtsp://admin:password@192.168.1.50:554/h265Preview_01_main"

[profiles.home-nas.camera_volume]
# Live View volume per camera ID, 0-100. Set from the slot's hover toolbar.
# 5 = 40

[profiles.home-nas.camera_muted]
# Live View mute state per camera ID. Cameras start muted.
# 5 = false

[profiles.home-nas.legacy_event_type_history]
# Event types each camera has ever produced before Surveillance Station
# 9.3, discovered by the Live View timeline's "Filter events" popover and
# kept so a later open only has to scan forward from checked_until
# instead of the whole history again.
# Written automatically; delete a camera's entry to force a full rescan.
# Also read under its old name, event_type_history; saving writes only
# legacy_event_type_history.
# [profiles.home-nas.legacy_event_type_history.5]
# types = [[3, 0], [33554435, 2]]  # (event type flag, reserved) pairs
# checked_until = 1771200000       # unix time this camera was scanned up to

[profiles.home-nas.camera_protocols]
# Stream protocol per camera ID:
# auto, websocket, mjpeg, rtsp_over_http, rtsp, multicast, direct
# "auto" is the same as "websocket"; there is no fallback between
# protocols, so pick one explicitly if WebSocket does not work.
# "websocket" uses a WebSocket stream bridged to mpv via an in-memory pipe,
# muxing in real audio via ffmpeg when the camera's audio codec is PCMU or AAC.
# "direct" uses the URL from camera_overrides.
# 5 = "direct"

[profiles.home-nas.camera_live_view_stream_profiles]
# Live View stream profile per camera ID, for WebSocket streams: high,
# balanced, low. A camera with no entry follows its Live View setting in
# Surveillance Station. See "Live View stream profile" below for how this
# combines with the Settings page.
# 5 = "high"

[profiles.home-nas.camera_dewarp]
# Fisheye dewarp per camera ID: panorama or ptz. A camera with no
# entry is shown as delivered.
# 5 = "ptz"

[profiles.home-nas.camera_fisheye_lens]
# Lens calibration per camera ID, only when the default (a centred full
# circle of 180 degrees) does not fit: "center_x,center_y,radius,fov".
# Set from Camera Settings with Calibrate… and Apply.
# center_x and center_y are fractions of the frame width and height,
# radius a fraction of its height, fov the lens's field of view in
# degrees. A missing or invalid field keeps its default.
# 5 = "0.5,0.5,0.48,180"

[profiles.home-nas.camera_fisheye_mount]
# How a dewarped fisheye hangs, per camera ID: "wall" for a lens looking
# straight out. A camera with no entry is on a ceiling, looking down.
# 5 = "wall"

The [session] section and each profile's camera assignments are managed automatically — the application restores the grid layout, active page, and camera assignments from the previous session on restart.

Settings keyed by camera ID (layouts, direct RTSP URLs, protocols, stream profiles, fisheye dewarp, lens and mount, volume, mute, the event-type cache and the cameras picked for searches) are kept per profile, under its own [profiles.<name>] table, since every NAS numbers its cameras from 1. A config from an earlier version kept one shared set at the top level and in [session]; it is read as the default profile's.

Stream protocols, direct RTSP overrides, Live View stream profiles and the fisheye dewarp, mount and lens can also be configured from the UI: right-click a camera in the sidebar for its Camera Settings.

Live View stream profile

Surveillance Station gives each camera up to three stream profiles (High quality, Balanced, Low bandwidth), each mapped to one of the camera's streams in its Live View settings there. Which one Live View plays is decided in this order:

  1. Settings page → Live View stream profile, when set to anything but "Use camera settings": every camera uses that profile.
  2. The camera's own Live View stream profile in Camera Settings.
  3. The camera's Live View setting in Surveillance Station, the default.

This applies to WebSocket streams only. An RTSP stream from Surveillance Station always carries the camera's Live View setting there, and a direct URL decides its stream itself. A lower profile saves network, decoding and memory, which matters most in a 3×3 or 4×4 grid.

[setting_overrides]/[setting_overrides_bool]/[setting_overrides_choice] are written by the Settings page (Licenses → Settings → About in the sidebar), not meant for hand-editing — use the page's own reset buttons instead.

Credentials are never stored in the config file. They are kept in the system keyring under the service name surveillance-station.

When two-factor authentication (2FA/MFA) is enabled on the Synology account, the client will prompt for a 6-digit OTP code after entering credentials. Checking "Trust this device" stores a device token in the profile so subsequent logins skip the OTP step. If the trust is revoked on the NAS, the client will prompt for OTP again automatically.

Development

Install dependencies

System packages

Identify, download and install the packages that match your distro. These must be installed before the Python dependencies.

Debian / Ubuntu

sudo apt install \
    gir1.2-gtk-4.0 \
    libgtk-4-dev \
    libmpv-dev \
    libmpv2 \
    libportaudio2 \
    ffmpeg \
    python3-gi \
    python3-gi-cairo \
    python3-cairo

Arch Linux

sudo pacman -S gtk4 mpv portaudio ffmpeg python-gobject python-cairo

Fedora

sudo dnf install \
    gtk4-devel \
    mpv-devel \
    portaudio \
    ffmpeg \
    python3-gobject \
    python3-cairo

openSUSE

sudo zypper install \
    gtk4-devel \
    mpv-devel \
    libportaudio2 \
    ffmpeg \
    python3-gobject \
    python3-gobject-cairo

FreeBSD

pkg install gtk4 mpv portaudio ffmpeg py311-gobject3 py311-cairo

OpenBSD

pkg_add gtk4 mpv portaudio ffmpeg py3-gobject3 py3-cairo

Python packages

Python 3.11 or later is required. These are installed automatically by pip:

Package Purpose
PyGObject >= 3.50 GTK4 bindings with native asyncio integration
pycairo >= 1.20 Cairo bindings, for the timeline ruler and presence bar
httpx[http2] >= 0.27 Async HTTP/2 client for Synology REST API
python-mpv >= 1.0 libmpv bindings for video rendering
PyOpenGL >= 3.1 OpenGL context for mpv render in GTK4 GLArea
keyring >= 25.0 Secure credential storage
tomli-w >= 1.0 TOML config writing
websockets >= 13.0 WebSocket stream bridge for live view
sounddevice >= 0.5 PortAudio bindings for push-to-talk mic capture
cryptography >= 42.0 AES for offline license activation
Install source code
  1. Make sure you have installed system dependencies for your distro
  2. Clone and install:
git clone https://github.com/renaudallard/surveillance-station-client.git
cd surveillance-station-client
python3 -m venv --system-site-packages .venv
source .venv/bin/activate
pip install .

Note: --system-site-packages is required so the venv can access the system-installed PyGObject and cairo bindings, which cannot be built via pip without extensive C development headers.

  1. Run:
surveillance
Building, testing and CI

Building an AppImage locally

./build-appimage.sh

This produces Surveillance-<version>-<arch>.AppImage in the project root. Requires libmpv, libportaudio2, ffmpeg and GTK4 development files on the build machine. Unless an appimagetool is already on PATH, the script downloads one from AppImage/appimagetool, which in turn fetches the statically linked AppImage runtime, so the build needs network access.

After PyInstaller runs, libpipewire-0.3.so.0, libasound.so.2 and libjack.so.0 are moved out of _internal/ into _internal/host-libs/<soname>/, one directory each, and AppRun puts one back on LD_LIBRARY_PATH only where the host has no copy of that library. They dlopen their plugins from paths compiled into them at build time, so a bundled copy running on another distribution loads that distribution's plugins into itself and crashes. Keep them out of _internal/.

Running checks locally

pip install -e ".[dev]"

ruff check src/ tests/       # lint (rules: E, F, W, I, B, S, SIM, RET, PLR, PLW, PLC, TRY, RUF)
ruff format src/ tests/       # format
mypy src/surveillance/        # type check
pytest tests/ -v              # tests

Automated build pipeline

CI runs automatically on push and pull requests to main:

Workflow Trigger What it does
lint.yml push / PR to main ruff check, ruff format, mypy
release.yml version bump on main Build AppImages (x86_64 + aarch64), create GitHub release
Architecture

Design overview

┌─────────────────────────────────────────┐
│  UI Layer          GTK4 widgets         │
│  window, sidebar, liveview, timeline,   │
│  recordings, player, slot_toolbar,      │
│  snapshots, events, timelapse,          │
│  licenses, settings, notifications      │
├─────────────────────────────────────────┤
│  Service Layer     domain logic         │
│  camera, live, ws_bridge, recording,    │
│  ptz, ptt, snapshot, event, homemode,   │
│  license, timelapse                     │
├─────────────────────────────────────────┤
│  API Layer         httpx (async)        │
│  client, auth, models                   │
└─────────────────────────────────────────┘

Three event systems are integrated:

  • GLib main loop drives the GTK4 UI
  • asyncio runs in a background thread, bridged to GLib via GLib.idle_add()
  • mpv threads bridge back to the main thread via GLib.idle_add()

Video is rendered through mpv's OpenGL render API into a Gtk.GLArea widget, which works on both X11 and Wayland without window ID embedding.

Project structure

surveillance-station-client/
├── pyproject.toml
├── README.md
├── LICENSE
├── TROUBLESHOOTING.md                   troubleshooting guide
├── surveillance.1                       man page
├── LEGACY_EVENT_BITMASK.md              event_map bitmask reverse-engineering reference (SSS before Event Center)
├── build-appimage.sh                    AppImage build script
├── appimage_entry.py                    PyInstaller entry point
├── assets/
│   ├── banner.svg                       README banner image
│   ├── divider.svg                      README section divider
│   └── screenshots/                     README feature screenshots (WebP)
├── scripts/
│   └── dump_event_map.py                diagnostic tool for extending LEGACY_EVENT_BITMASK.md
├── data/
│   ├── org.surveillance.app.desktop
│   └── icons/hicolor/scalable/apps/
│       └── org.surveillance.app.svg  app icon
├── .github/ISSUE_TEMPLATE/
│   ├── bug_report.yml                   bug report form
│   └── config.yml                       issue template config
├── .github/workflows/
│   ├── lint.yml                         CI: ruff + mypy
│   ├── check-debug-logs.yml             CI: validates bug-report debug logs
│   ├── stale-issues.yml                 CI: closes stale issues
│   └── release.yml                      AppImage build + GitHub release
├── src/surveillance/
│   ├── __main__.py                      entry point
│   ├── app.py                           Gtk.Application
│   ├── config.py                        TOML config + XDG paths
│   ├── credentials.py                   keyring wrapper
│   ├── logfile.py                       --log-file handling + redaction
│   ├── settings_registry.py             tunable constants behind the Settings page
│   ├── data/
│   │   ├── style.css
│   │   ├── legacy_event_bits.json       event_map bit -> label table (see LEGACY_EVENT_BITMASK.md)
│   │   └── icons/                       the app's own symbolic icons (surveillance-*-symbolic)
│   ├── api/
│   │   ├── client.py                    SurveillanceAPI (httpx)
│   │   ├── auth.py                      login / logout / SID
│   │   └── models.py                    dataclasses
│   ├── services/
│   │   ├── camera.py                    camera list
│   │   ├── live.py                      stream URL resolution
│   │   ├── ws_bridge.py                 WebSocket-to-pipe bridge
│   │   ├── recording.py                 recording management
│   │   ├── download.py                  recording/snapshot download helpers
│   │   ├── ptz.py                       PTZ commands
│   │   ├── ptt.py                       push-to-talk session (AudioOut WebSocket)
│   │   ├── g711.py                      G.711 mu-law encoder
│   │   ├── aac.py                       AAC frame recovery + ADTS headers
│   │   ├── snapshot.py                  snapshot management
│   │   ├── event.py                     shared event/recording-interval helpers + alerts
│   │   ├── event_backend.py             EventBackend protocol + backend selection
│   │   ├── event_center.py              EventCenterBackend (Event Center API, SSS 9.3+)
│   │   ├── legacy_event.py              LegacyEventBackend (event_map decoding, SSS before Event Center)
│   │   ├── legacy_event_bits.py         event_map bitmask decoder (see LEGACY_EVENT_BITMASK.md)
│   │   ├── homemode.py                  home mode toggle
│   │   ├── transactions.py              POS transactions (receipt overlay)
│   │   ├── license.py                   license management
│   │   ├── timelapse.py                 time lapse management
│   │   └── update_check.py              GitHub release version check
│   ├── ui/
│   │   ├── window.py                    main window
│   │   ├── about.py                     About page
│   │   ├── login.py                     login dialog
│   │   ├── headerbar.py                 header bar controls
│   │   ├── sidebar.py                   camera list sidebar
│   │   ├── camera_settings.py           per-camera settings dialog (protocol, direct URL, stream profile, fisheye dewarp and mount)
│   │   ├── liveview.py                  live stream grid
│   │   ├── layouts.py                   grid layout definitions
│   │   ├── timeline.py                  Live View timeline strip
│   │   ├── date_time_picker.py          calendar date/time picker
│   │   ├── event_type_filter.py         Filter-events popover
│   │   ├── slot_toolbar.py              per-slot hover toolbar (audio, PTT, PTZ, snapshot)
│   │   ├── mpv_widget.py                GLArea + mpv render
│   │   ├── fisheye.py                   fisheye dewarp (panorama, virtual PTZ) shader and view state
│   │   ├── lens_calibration.py          fisheye lens calibration dialog over the camera's picture
│   │   ├── rtsp_health.py               RTSP stream stall/health monitor
│   │   ├── recordings.py                recording browser
│   │   ├── advanced_search.py           advanced search dialog (shared by Recordings/Snapshots/Events)
│   │   ├── player.py                    playback controls
│   │   ├── snapshots.py                 snapshot browser
│   │   ├── events.py                    event list
│   │   ├── licenses.py                  license management
│   │   ├── timelapse.py                 time lapse browser
│   │   ├── notifications.py             alert popover
│   │   ├── settings.py                  Settings page
│   │   └── labels.py                    combo label helpers shared by the browser pages
│   └── util/
│       ├── async_bridge.py              GLib + asyncio bridge
│       └── resource_log.py              periodic memory/descriptor line on a debug run
└── tests/
    ├── conftest.py
    ├── test_aac.py
    ├── test_advanced_search.py
    ├── test_api_client.py
    ├── test_camera_settings.py
    ├── test_config.py
    ├── test_date_time_picker.py
    ├── test_fisheye.py
    ├── test_legacy_event_bits.py
    ├── test_icons.py
    ├── test_liveview_persistence.py
    ├── test_liveview_state.py
    ├── test_logging.py
    ├── test_login_dialog.py
    ├── test_models.py
    ├── test_mpv_env_options.py
    ├── test_mpv_fence_workaround.py
    ├── test_mpv_profiles.py
    ├── test_resource_log.py
    ├── test_rtsp_health.py
    ├── test_services.py
    ├── test_settings_registry.py
    ├── test_timeline.py
    ├── test_transactions.py
    ├── test_ui_behavior.py
    ├── test_update_check.py
    └── test_ws_bridge.py
Synology API Reference

Endpoints used by this client

API Purpose
SYNO.API.Info Discover available APIs and CGI paths
SYNO.API.Auth Login / logout / session management
SYNO.SurveillanceStation.Camera Camera list, snapshots, live view paths
SYNO.SurveillanceStation.PTZ Pan, tilt, zoom, presets, patrols
SYNO.SurveillanceStation.AudioOut Push-to-talk busy-check (CheckOccupied) — the actual audio upload is a raw WebSocket, not a REST call
SYNO.SurveillanceStation.Recording List, stream, download recordings
SYNO.SurveillanceStation.RecordingPicker Per-camera recording intervals behind the Events page and the Live View timeline: recording presence and the recording file each event plays back from; before 9.3, also the motion/alarm history, decoded bit-by-bit into brand-aware categories — see LEGACY_EVENT_BITMASK.md
SYNO.SurveillanceStation.EventCenter.Event Typed events behind the Events page and the Live View timeline (List), and the event types the NAS supports (Capability), on 9.3 and later
SYNO.SurveillanceStation.Stream Recording playback URL (EventStream), with SYNO.SurveillanceStation.Streaming as the legacy fallback
SYNO.SurveillanceStation.SnapShot List, take, download, delete snapshots
SYNO.SurveillanceStation.TimeLapse Time lapse task listing
SYNO.SurveillanceStation.TimeLapse.Recording Time lapse recording management
SYNO.SurveillanceStation.Notification Alert list, unread count, mark read
SYNO.SurveillanceStation.HomeMode Get/set home mode status
SYNO.SurveillanceStation.Transactions.Device POS devices and the camera each is paired with
SYNO.SurveillanceStation.Transactions.Transaction Latest POS receipts for the Live View overlay (Enum)
SYNO.SurveillanceStation.License License management
SYNO.SurveillanceStation.Info NAS device info

Support

If you find this project useful, you can support its development:

PayPal

Disclaimer

This project is not affiliated with, endorsed by, or sponsored by Synology Inc. Synology, Surveillance Station, and DiskStation Manager (DSM) are trademarks of Synology Inc. This software is an independent, third-party client that interacts with the publicly documented Synology Web API. Use it at your own risk.

License

BSD-2-Clause — see LICENSE for details.

Copyright (c) 2026, Renaud Allard <renaud@allard.it>
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice,
   this list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
   this list of conditions and the following disclaimer in the documentation
   and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

About

Native GTK4 desktop client for Synology Surveillance Station on Linux and BSD

Topics

Resources

Stars

15 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages