monitors/alarm/command:on now takes an optional cause (query or POST data)
and passes it to zmu --cause, so the event records it in place of
"Forced Web". Without a cause nothing changes.
The cause is stored as the event's Cause and in its Notes, so alarmCause()
keeps it to plain text: letters, digits, spaces and plain punctuation, cut
to the 31 bytes zmu stores on a character boundary, falling back to the
default when nothing is left. The value is quoted with escapeshellarg()
for the zmu command line.
Test: tests/php/test_monitor_alarm_cause.php
fixes#5348
The only Docker page was packpack.rst, which builds packages in a
container rather than running ZoneMinder. The project now publishes
images on Docker Hub and GHCR from the zmdockerfiles repo.
The new page, listed after RedHat in the installation guide, covers the tags, the
compose quick start, first-run setup, volumes and ports, go2rtc live view
(with a warning that its streams aren't behind ZoneMinder's login),
hardware decoding, the event server images, moving from the old images,
and verifying signatures. It links to the zmdockerfiles README for the
full list of settings rather than repeating it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKoPm5qySMSsCYJmCo8r2J
AVCodecID values are renumbered between FFmpeg major versions (HEVC is
173 in libavcodec 61 and 172 in libavcodec 63, where 173 is fic), so a
consumer linked against a different FFmpeg than zmc misidentifies the
codec. Add HELLO TLV 0x0B with avcodec_get_name(), keep sending the
numeric id for older consumers, and have ParseHello resolve the id from
the name whenever this FFmpeg knows it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add ZM_PASSWORD_MIN_LENGTH (Options, System; default 8, 0 turns it off).
passwordProblem() refuses a shorter password, so the user page, the API
and both login-page forms apply it alike. Existing passwords keep
working; only a password being set is checked. The length counts
characters, not bytes.
There is no setting for character classes: they push people to
predictable variations (Password1!) and add less than length does.
A database whose Config table doesn't have the option yet (an upgrade
not run with zmupdate.pl -f) gets no minimum. The password inputs carry
minlength so the browser says so before the form is sent; the server
check is the one that counts. The API answers with the English text, as
for the other reasons.
Tests: tests/php/test_default_password.php.
A new install starts with authentication on and the admin password admin,
which is refused everywhere. Asking for that password just to replace it is
an extra step, and some installs don't want a login at all. Ask instead.
While the first run is pending (authentication on, the privacy page not
answered yet, builtin auth, and admin still has the password admin), the
login page shows "Set up ZoneMinder" instead of the login form:
- Require a login: choose the admin password; the account is then logged
in and the privacy page follows, as before.
- Don't require a login: ZM_OPT_USE_AUTH is set to 0 in the database.
Not offered when ZM_OPT_USE_AUTH is set in zm.conf or conf.d: the owner
of the box has decided then, and only the password is asked for.
Until one is chosen the web interface, API and streams can't be used: the
password admin is still refused. Whoever opens the page first makes the
choice; before this, whoever typed admin/admin did. The form carries a
nonce kept in the session, like the default-password form, and is exempt
from csrf-magic as action=login is (the login regenerates the session id).
Both choices are audited. Upgrades are not affected: ZM_SHOW_PRIVACY is 0
once the privacy page was answered, and zmupdate.pl doesn't reset it.
Docs: a First Run section in the getting-started guide, and the FAQ and
system options updated to match.
Tests: tests/php/test_first_run.php.
db/zm_create.sql seeds the admin account with the password the user
guide documents (admin), so with authentication on a new install is
still open to anyone who reads the guide.
Refuse that password as a credential and have the web login replace it:
- validateUser() returns false with "This account uses the password
admin, which ZoneMinder does not accept. Log in to the web interface
to set a new one." when the password is right but is admin. The API (login.json and
user=/pass= on any request) answers 401 with that message; the
user=/pass= web parameters are refused the same way.
- On the login page (action=login) the same login shows a form that
asks for the current password again and a new one, with a nonce kept
in the session so another site can't submit it for the user. The new
password must be set, confirmed and not the default. The account is
logged in only once it has been replaced; the change is audited.
That POST skips reCAPTCHA when it carries the form's nonce: the form
follows a login that passed it, and doesn't render the widget.
With ZM_AUTH_RELAY=plain the session keeps the new password.
- zmLoadUser() refuses the default password, so zms, zmu and the RTSP
server don't accept it in user/password parameters either.
The check is on the password typed, so it needs no schema change, covers
existing installs whose admin still has the default password, and
follows the account if it is renamed. A password set to the default
again later is caught the same way. Sessions, tokens and auth hashes
issued before the upgrade keep working until they expire; with
ZM_AUTH_TYPE=remote the web server's login is not affected.
The FAQ now says to force authentication on in conf.d: since 4d7609d36
the daemons apply zm.conf and conf.d values over the database too.
Tests: tests/php/test_default_password.php (the helpers, and that the
seeded hash is the documented default), tests/zm_user_default_password.cpp.
ZM_OPT_USE_AUTH shipped as 0, so a new install accepted every request as
the default user with System=Edit. That includes requests a browser sends
on behalf of any web page a user on the same network opens, so a server
that is never exposed to the internet could still be driven from it.
Default it to 1. zm_create.sql (generated from ConfigData) gives new
installs authentication on. Existing installs keep their value:
zmupdate.pl -f loads Value from the database and writes it back, and only
DefaultValue changes. zm_update-1.39.41.sql also sets DefaultValue to 1
for the row, so that an upgrade run without -f doesn't leave
Value = DefaultValue = 0, which the C++ config loader would skip in favour
of its compiled-in default (on) while the web UI read off.
Update the option help and the user guide (getting started, FAQ, system
options) to say authentication is on for new installs and kept on
upgrade. tests/perl/test_auth_default.pl checks the default, the stored
value and that no update script sets the row's Value; tests/zm_config.cpp
now expects the compiled-in default (opt_use_auth) to be on.
ZM_AUTH_HASH_SECRET and the reCAPTCHA keys are Private string options, so
an admin saw them in a plain text input; only password options were
masked. Private options now use the same masked input and visibility
toggle as password options. The input asks for autocomplete
"new-password", so the browser doesn't fill in a saved login password
that a save would then store in the option.
A Private or password option set in zm.conf or conf.d can't be changed
on this page, so its value is left out of the page altogether: the
masked input holds the placeholder "<set in config file>", is disabled
and has no toggle, and the line naming the file says the value is not
shown (OptionSetInConfigFileHidden). Users who can't edit System
settings saw no Private value before and still don't.
docs/userguide/configfiles.rst says how secrets are shown.
zm.conf and conf.d are set by the system owner and win over the Config
table in PHP, Perl and, since 4d7609d36, the daemons. The Options page
still offered those options for editing and showed the database value,
with a note only when the two differed.
An option a file sets is now shown with the value in effect (a boolean
word read as in configFileValue()), its input disabled, and one line
naming the file ('OptionSetInConfigFile', through translate()). That
line replaces the old "overriden via configuration files" note. A row
whose option a file sets gets no data-requires, so the page script
doesn't enable its input again, and the Requires check of other options
reads the value in effect.
actions/options.php skips options a file sets. A disabled checkbox is
left out of the form, and the action saved a missing boolean as 0, so
without the skip a save of the tab would have reset the Config row; a
hand-made POST can't change it either. The Config row keeps whatever the
admin last saved, which applies again once the file line is removed.
loadConfigFiles() is split out of loadConfig() so the view and the
action can tell which names come from a file and which file. Each entry
carries that file; loadConfig() drops the key before returning, so
configs/viewByName answers as before.
docs/userguide/configfiles.rst describes setting an option in a file.
With reCAPTCHA on, a wrong site key, a blocked reCAPTCHA script or a
server that can't reach www.google.com refuses every login on the login
page. Document the two ways back in: a conf.d override, which the web UI
reads before the database, or an UPDATE of the Config row.
Nothing links or dlopens libvlc any more, so the dev package is no
longer a build requirement anywhere: the debian, beowulf, ubuntu and
split-package control files, the podman Containerfile, the redhat and
opensuse specs, the centos CI image, and the five workflows that
installed libvlc-dev. The redhat spec also required vlc-core at
runtime, which was the dlopen target.
Documentation follows: the libVLC source tab section, the macos note
about libVLC not being found, and the WEB_FILTER_SOURCE source-type
list.
Two stale statements nearby are corrected while the files are open. The
Remote source tab still said its RTSP method "may be removed in the
future"; it was removed earlier on this branch. AGENTS.md still listed
RemoteCameraRTSP and LibVLCCamera among the camera classes, both of
which are now gone.
All workflow yaml parses, the three edited rst files render, and the
debian control field structure is unchanged apart from the removed
entries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
With ZM_AUTH_HASH_IPS on, the client address bound into the auth hash was
taken from the left-most X-Forwarded-For value whenever the header was
present, both when PHP generated the hash (getRemoteAddr()) and when PHP or
zms validated it (getAuthUser(), zmLoadAuthUser()). The header is client
controlled, so anyone holding a leaked hash could replay it from anywhere by
sending the address it was bound to.
Add ZM_AUTH_TRUSTED_PROXIES, a list of exact reverse proxy addresses.
X-Forwarded-For is now used only when REMOTE_ADDR is one of them, and is read
from the right, skipping hops that are themselves listed proxies, so values a
client prepends are never chosen. With the option empty, the default, the
header is ignored and REMOTE_ADDR is used.
PHP (web/includes/Network.php getRemoteAddr()) and C++ (ClientAddress() in
zm_utils, used by zmLoadAuthUser()) implement the same rule so generation and
validation continue to agree. Every PHP caller already routes through
getRemoteAddr(), so session.php and auth.php need no change.
Reverse proxy users who enable ZM_AUTH_HASH_IPS must list their proxy in the
new option; until they do, hashes bind to the proxy address, which still
validates but no longer distinguishes clients. This is the behaviour change
that the #4921 work avoided by trusting the header.
The option is added through ConfigData only, like other recent options;
zmupdate.pl --freshen inserts it, zms falls back to the compiled-in default and
PHP treats an undefined constant as empty, so no schema migration is needed.
Tests: ClientAddress Catch2 case; tests/php/test_remote_addr.php updated for
the trusted-proxy rule.
refs GHSA-72rf-54rm-798c
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit acea5889dec0826f595cb736147a5fdc9c94a2a9)
The cached snapshot was framed when the status last changed, so after a
generation bump or further events a new consumer received it stamped
with a stale generation and an old event-sequence baseline. Keep only
the body and frame it in AcceptClient, so the header carries the
generation and sequence in effect at the moment of connection.
Tests: a snapshot cached at generation 0 with no events is delivered to
a later consumer with the current generation and sequence.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two problems with announcing streams one at a time. SetAudioParams only
bumped the generation when audio had been announced before, so audio
joining a video-only stream was appended to the current generation after
its video HELLO, which the protocol says is the last HELLO of a
generation. And a re-prime that changed both streams bumped twice:
consumers saw, and a connecting consumer was handed, an intermediate
generation pairing the new audio with the old video, which
zm_rtsp_server built a session for and tore down again.
Add StreamSocket::SetStreams(video, audio), which applies the whole set
under one lock: unchanged is a no-op, the first announcement stays in
generation 0, and any change once a video HELLO has gone out - new
parameters, a stream appearing, a stream disappearing - is exactly one
bump with every remaining stream re-announced, audio first. The single
stream setters and the new ClearVideoParams are wrappers over the same
logic, and a null or codec-less parameter set means "no such stream".
PrimeCapture passes both streams in one call.
Tests: audio joining an announced video stream bumps the generation;
SetStreams keeps the initial announcement in generation 0, changes both
streams in one generation with consistent pairing, and drops a video
stream the source no longer has.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Correct three problems in the stream socket generation tracking added by
the previous review fix-up:
- ClearAudioParams bumped the generation without restarting the video
sequence or dropping the cached keyframe, so a late joiner received a
HELLO at generation N+1 followed by a KEYFRAME still stamped N, and
sequences did not restart as documented. It now does the same full bump
as SetVideoParams/SetAudioParams.
- zm_rtsp_server rebuilt the xop session whenever the generation changed,
even with identical codec parameters. Generations restart at 0 when zmc
restarts, so every zmc restart dropped the RTSP clients; the original
code kept the session in that case. Unchanged parameters now just adopt
the new generation without a teardown.
- Deciding whether audio belongs to the current generation by comparing
per-stream generation numbers raced the two HELLOs of a generation
(double rebuild when Update() ran between them) and cannot tell a
producer restart apart. The producer now sends the audio HELLO before
the video HELLO within a generation (on connect and on every bump, and
PrimeCapture announces audio before video), so the video HELLO always
completes a generation's parameter set. The consumer forgets the
previous generation's HELLOs when a new generation starts and on
disconnect, and builds only once the video HELLO of the latest
generation is in. It also records whether audio was announced at build
time rather than whether a packer was created, so an unsupported audio
codec no longer triggers a rebuild on every pass.
The ordering guarantee is documented in the protocol header and the
stream socket docs. Tests pin the audio-first order on connect and on a
video reconfigure, and the keyframe drop and sequence restart on audio
removal.
refs #5143
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4UcdJLt1bxwdpcigGxZRD
- Describe pts_us as signed microseconds with AV_NOPTS_VALUE for unknown.
- Document that the socket path is published in the shared-memory
stream_socket_path field, not only derivable from the convention.
- Clarify the snapshot event's sequence: it equals the next EVENT's
sequence, and the snapshot is a state message a consumer must not treat
as a duplicate of that following EVENT.
refs #5143
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4UcdJLt1bxwdpcigGxZRD
The MEDIA description promised Annex B for H.264/H.265, but zmc forwards
packets as the camera's demuxer produced them: RTSP sources give Annex B,
MP4/MKV files give AVCC length-prefixed NALs. Say so, explain how a
consumer tells them apart from the HELLO extradata, and note that
zm_rtsp_server handles Annex B only. Also document that image-only
cameras announce no media stream, that the keyframe cache is cleared
when the camera closes, that the uid check uses getpeereid off Linux,
and that the C++ consumer now surfaces EVENT frames.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Wire the monitor lifecycle events into zmc. Monitor::SetState() replaces
the scattered shared_data->state assignments in Analyse() and connect(),
publishing a state_changed event (with previous and new state id and name)
whenever the analysis state actually changes. Monitor::SendStreamHealthEvent()
emits the capture-fault edges and tracks the active fault for the snapshot;
the snapshot is refreshed on every state or health change and after a
successful prime, so a consumer connecting mid-fault learns current status.
zmc's capture loop emits the six health transitions once per edge:
connection failed/restored around connect(), prime_capture failed/restored
around PrimeCapture(), capture_failed on pre/capture/post failure, and a
single capture_resumed once the pipeline recovers. Because the stream
socket survives camera reconnects, these are observable exactly when media
has stopped. A small mutex guards the health fields shared between the
capture thread (health events) and analysis thread (state events).
Documents the EVENT frame in docs/stream_socket.rst and decodes it in
tools/zm_stream_socket_dump.py. Full build and ctest pass (121 tests).
refs #2875
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds docs/stream_socket.rst covering the socket path convention, access
control and queue tuning settings, and the version 1 wire protocol
(header layout, HELLO TLVs, MEDIA/KEYFRAME/STATS/BYE semantics,
sequence/generation loss and discontinuity signalling), with pointers
to the reference encoder/decoder, the C++ consumer class and the python
dump tool. Linked from the documentation index.
This also serves as the migration reference for external consumers of
the removed media FIFOs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The guide was written from a verified build and install, but the steps
past that point were derived from the Debian packaging rather than run.
Working through them on Apple Silicon to a running system turned up
three things that were wrong or missing.
mysql -u root does not work. A Homebrew MariaDB authenticates root with
the unix_socket plugin, so only the operating system's root matches it;
your own account gets the administrative account, which is what plain
mariadb uses.
Nothing said ZoneMinder has to be started as ZM_WEB_USER. zmpkg.pl
compares the current user against it and tries sudo -u and two forms of
su to become it, all of which fail for an ordinary user, and stops with
"Unable to find valid su syntax". systemd hides this on Linux by
starting the unit as the web user. Now says so, and gives the
workstation alternative of running everything as one account.
The web server section did not mention that zms is a CGI binary. Apache
runs those with mod_cgi; nginx needs a wrapper such as fcgiwrap, which
Homebrew does not package, so with nginx alone the console works and
live streams do not. Also noted that the generated samples reference
/etc/pki, /etc/nginx and /run/fcgiwrap.sock, none of which exist here.
Added the hardcoded taint-safe PATH to the known gaps, since it is what
breaks zmupdate.pl on Apple Silicon: seventeen scripts pin
/bin:/usr/bin:/usr/local/bin, so the database client in the Homebrew
prefix cannot be found. Worth making configurable - it would equally
affect a --prefix=/opt install on Linux. Sys::CPU is listed too; it has
been removed from CPAN and only zmtelemetry.pl uses it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5KL9Xbi7K5aGsauLtd8tG
ZoneMinder now builds and installs on macOS, but the steps that make it
work existed nowhere. The installation guide has pages for Debian,
Ubuntu, Redhat and WSL, and nothing for macOS.
Covers the parts that are not guessable, each one walked through on a
real machine rather than reasoned about:
- Sys::Mmap, Date::Manip and a DBD driver are missing from the Perl Apple
ships, and configure only warns, so it is easy to get a clean build and
a system whose Perl daemons cannot read monitor state at all. A
Homebrew Perl additionally needs DBI and LWP::UserAgent, since it has
none of the extras Apple bundles.
- DBD::MariaDB does not install without help. mysql_config hands its
configure step -lzstd -lssl -lcrypto, but a Homebrew Perl's ldflags
carry no -L for the Homebrew prefix, so it fails with "Can't
link/include C library 'zstd', 'ssl', 'crypto', aborting". The guide
gives the --libs and --cflags that get it through.
- ZoneMinder's Perl modules land in vendorlib and survive Perl upgrades;
the CPAN modules land in sitelib, which resolves into the Cellar and
does not. Worth knowing before an upgrade quietly breaks the daemons.
- ZM_NO_MMAP is not a way around Sys::Mmap. macOS caps SysV shared memory
at 4 MiB per segment across 8 segments, and a 1080p RGB frame does not
fit in one.
- mysql-client is keg-only, so the build needs an explicit -I or it stops
at "'mysql/mysql.h' file not found".
- Nothing creates the runtime directories. On Linux the package does it.
- The test binary has to run from build/tests, because zm_font.cpp loads
its fixtures by relative path.
- zmpkg.pl falls back to zmdc.pl without systemd, so there is a way to
start ZoneMinder before a launchd plist exists.
Honest about what is missing rather than papering over it: no plist, no
Homebrew formula, no log rotation, no local cameras, and the arp-scan and
ip warnings configure emits. Also separates Homebrew's prefix from
ZoneMinder's install prefix, which differ on Apple Silicon and are easy
to conflate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5KL9Xbi7K5aGsauLtd8tG
The Recording tab docs said only that 'the parameters to the command
will be the event id and the monitor id', which is true only when the
command contains no '%' - and hides several surprises discovered by
reading zm_monitor.cpp:
- without '%' the whole string is exec'd as ONE executable path with
event id / monitor id appended; a command with inline arguments
('/path/script.sh start') is treated as a single file name and fails;
- with '%' the string runs via /bin/sh -c after %EID%/%MID%/%EC%
substitution, and nothing is appended automatically (%EC% exists for
the start command only);
- the child runs in the background as the capture-process user with all
file descriptors closed, so any output silently disappears;
- the end command fires after the event is finalized, so the video file
is complete by then.
Document all of that where the two options are described.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a ZM_DB_SSL_VERIFY_SERVER_CERT setting so a database connection that uses
ZM_DB_SSL_CA_CERT can talk to a server with a self-signed or otherwise
non-matching certificate. When enabled, verification is by identity (the cert
must chain to the CA and its CN/SAN must match ZM_DB_HOST), consistent across
the C++ daemons, the PHP web interface, the CakePHP API and the Perl scripts.
This re-does the reverted #3817. That PR broke the build because it called
mysql_options(MYSQL_OPT_SSL_VERIFY_SERVER_CERT, ...), and that enum was removed
from the MySQL 8.0 C client in favour of MYSQL_OPT_SSL_MODE; it also passed a
c_str() where a my_bool* was expected, and referenced the PHP constant
unconditionally (fatal on PHP 8 for an upgraded install whose zm.conf predates
the option).
The option that controls server-cert verification differs by client library and
the symbols are enum values, not macros, so CMake feature-detects them by
compiling:
- HAVE_MYSQL_OPT_SSL_MODE (MySQL 5.7.11+/8.0, MariaDB Connector/C 3.1+)
- HAVE_MYSQL_OPT_SSL_VERIFY_SERVER_CERT (older MariaDB/MySQL)
zm_db.cpp uses SSL_MODE_VERIFY_IDENTITY / SSL_MODE_REQUIRED when the former is
available, else falls back to the latter with a proper my_bool.
Value handling is three-way in every layer: a truthy value verifies, a false-y
value (0/false/no/off) skips verification, and an empty/unset value leaves the
client default in place so existing installs are unchanged on upgrade. PHP, the
API datasource (via PDO flags) and the Perl DSN are all guarded with defined()
checks. Fresh installs default to 1.
Documents the full ZM_DB_* connection and SSL settings, including the hostname
verification gotcha when connecting by IP, in docs/userguide/configfiles.rst.
refs #3816
Adds a curated, per-encoder parameter-template library to ZoneMinder:
- Monitor edit page: a new Template row above the EncoderParameters
textarea offers per-encoder templates (Balanced / Archival / Low
Power / Low CPU). Apply merges the template's params into the
textarea, preserving user-only keys. Advisory lint flags option
keys that aren't recognised for the selected encoder. Switching
encoders offers a same-name template on the new encoder via a
native confirm.
- Options page: a new Encoder Templates tab with full CRUD —
list / edit / copy / delete — backed by a new CakePHP REST API
at /api/encoder_templates.
- Storage: a new EncoderTemplates DB table seeded with 14 shipped
defaults across libx264 / libx265 / h264_nvenc / hevc_nvenc /
h264_vaapi / hevc_vaapi. The table is mutable; ZM upgrades do not
re-seed user-edited rows.
- valid_keys (the lint allow-list) stays in PHP code as ffmpeg
vocabulary, not user data.
- Default params explicitly include pix_fmt to avoid the yuvj420p
HEVC HW-decode rejection issue we hit earlier.
No C++ change. The textarea content is parsed by the existing
av_dict_parse_string call in src/zm_videostore.cpp.
version.txt -> 1.39.6.
Specs: docs/superpowers/specs/2026-05-0{1,2}-*.md
Plans: docs/superpowers/plans/2026-05-0{1,2}-*.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the uninformative "Any ONVIF options required. This is an
optional field." with a full reference covering:
- key=value,key=value format with an example
- pull_timeout, subscription_timeout, max_retries,
timestamp_validity, soap_log, renewal_enabled, expire_alarms,
closes_event — including defaults, valid ranges, and when to use each
Also fixes pre-existing typo: "alam" → "alarm".
Agent-Logs-Url: https://github.com/ZoneMinder/zoneminder/sessions/149f7873-ca12-424f-85d4-4bdc179d2488
Co-authored-by: connortechnology <925519+connortechnology@users.noreply.github.com>
Add dedicated documentation page for the zma binary covering synopsis,
all command-line options, operating modes (update-existing vs create-events),
save-analysis functionality, and usage examples. Add zma entry to the
components page and user guide toctree.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Document the need to add www-data to the video group for local camera
access. The package postinst now does this automatically, but source
installs still require the manual step.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>