Commit Graph
203 Commits
Author SHA1 Message Date
Isaac Connor e924bfb999 fix: treat a request parameter that is not a string as absent in auth
Saving a user from the web ui took the whole auth path down:

  PHP Fatal error: Uncaught TypeError: strcasecmp(): Argument #1 ($string1)
  must be of type string, array given in includes/auth.php:197
  #0 auth.php(197): strcasecmp()
  #1 auth.php(528): getAuthUser()
  #2 auth.php(687): userFromSession()

reached from ?view=user&uid=2. That page's form posts user[Username],
user[Password], user[Name] and the rest, so $_REQUEST['user'] is an array on
every save from it, and getAuthUser() read that parameter as the username to
filter on and handed it to strcasecmp(). Under PHP 8 a string function given an
array is a TypeError rather than a warning, so the request died with a 500.

The same shape arrives from anyone who cares to send it, and not only on a page
that needs a session. userFromSession() reads user, pass, username, password
and auth straight out of the request, and the credential branches run before
anyone is logged in, so ?username[]=x&password[]=y reaches validateUser() with
arrays on an install that has never seen the caller before.

requestString() returns a parameter only when it is a string and null
otherwise, which is what the callers already do with a parameter that was not
sent. An array is not a username, a password or an auth hash.

master no longer has the strcasecmp line the report names, so it does not fatal
in that exact spot, but it reads the same unvalidated values: $filterUser is
bound as a query parameter and the credentials still reach validateUser(). This
fixes the class rather than the one line, and backports to 1.38 where the
reported line lives.

The test lifts requestString() out of auth.php and evaluates it alone, because
including auth.php needs a database; test_auth_no_include_side_effects.php
sidesteps the same dependency the same way. 8 cases, covering the form's array,
the login parameters, a nested array and the strings that must still pass
through. 5 of them fail without this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 1f4aa1a16c1b63fdf34a6f2c6aef6590dbd45acd)
2026-09-24 19:44:16 -04:00
Isaac Connor 8f5a0af862 Merge pull request #5143 from SteveGilvarry/feature/stream-socket-events
Replace the per-monitor media FIFOs with a unix stream socket
2026-09-23 18:17:50 -04:00
Steve GilvarryandClaude Fable 5.1 f023032488 test: keep the stream socket benchmark's pacing deadline signed
kPacketInterval * i with a size_t i gives the deadline an unsigned
duration; once the producer falls behind, libc++'s sleep_until turns the
negative remaining time into a near-infinite sleep and the benchmark
hangs. Multiply by a signed index.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 15:07:15 +10:00
Steve GilvarryandClaude Fable 5.1 0ac5a2cc83 fix: refuse over-long stream socket paths and media for unknown stream ids
zm::UnixSocket copies the path with a truncating strncpy, so a
PATH_SOCKS long enough to overflow sun_path made the server bind one
file while chmod, chown and unlink acted on another, and the client
connect to a truncated, different path. Both now refuse such a path with
an error. Start() also closes the listener on its own failure paths.

SendMedia indexed the two-entry sequence array with the stream id; a
caller passing StreamId::Monitor would have written past it. Ignore
anything that is not video or audio.

Tests: server and client refuse a 200 character path; a Monitor stream
id is ignored and does not disturb the video sequence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 15:07:15 +10:00
Steve GilvarryandClaude Fable 5.1 e917315f12 fix: gate rtsp media on a session confirmed against the latest HELLO set
on_media admitted a packet whenever its generation matched the one the
session was built for. A restarted zmc starts again at generation 0, so
if the camera came back with different parameters its replayed keyframe
passed that check and went into the old codec's packer before the main
thread rebuilt the session.

Move the HELLO and generation bookkeeping into RtspSessionTracker, a
small class with no locking or RTSP types. Every HELLO and every
disconnect clears the confirmed flag; Update() asks the tracker for a
plan (none, adopt, rebuild), acts on it and confirms; on_media feeds the
packers only for a confirmed session at the confirmed generation. A set
that failed to build (unsupported codec) is not retried until a new
HELLO arrives, so a bad camera does not rebuild on every pass.

Tests: a new suite for the tracker covers the complete-set rule, the
HELLO-to-rebuild window, generation reuse after a producer restart with
changed and with unchanged parameters, audio appearing, disappearing and
changing, a generation without video, a failed build, and HELLOs for
streams the server does not serve.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 15:07:15 +10:00
Steve GilvarryandClaude Fable 5.1 3d98b216ca fix: frame the stream socket snapshot when a consumer connects
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>
2026-09-23 15:07:15 +10:00
Steve GilvarryandClaude Fable 5.1 7d9d97f387 fix: apply both stream parameter sets to the stream socket in one generation
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>
2026-09-23 15:07:14 +10:00
Isaac ConnorandClaude Opus 5 bdf688eecb fix: carry the zone's alarm colour on the alarmed and filtered pixel methods
The alarmed/filtered pixel check methods handed Overlay() the raw GRAY8
scoring mask. Overlay keys on a non-zero source pixel and copies that byte, so
the highlight took whatever shape one byte has in the destination format: the
red channel on an RGB32 monitor, which is the whole reason alarms have always
come out red; all three channels on RGB24, giving white; luma alone on YUV420.
The zone's configured Alarm Colour was honoured only on the blob path, which
goes through HighlightEdges.

Generalise HighlightEdges into BuildHighlight, which takes an edges_only flag
and otherwise fills every marked pixel, and build the highlight for the pixel
methods the same way the blob path already builds its outline: in the
capture's own pixel format, carrying alarm_rgb, once scoring is finished with
the GRAY8 mask. HighlightEdges stays as a thin wrapper so the blob path and
its callers are unchanged.

This is a behaviour change: monitors left on the default red see no
difference, but a zone configured with any other Alarm Colour now paints that
colour instead of red or white.

Reverting the zone hunk fails the new test in all three of its sections.
Full suite: 148 cases, 12535 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBHBB95RBX7D9p8ge2WDZb
2026-09-22 09:57:19 -04:00
Isaac ConnorandClaude Opus 5 5b2dd2abbe fix: do not fault in the shm time accessors before connect() has mapped
Monitor::connect() returns false with shared_data still null on every one of
its failure paths: the mmap file cannot be opened (wrong ownership, e.g.
after a package upgrade), fstat fails, ftruncate cannot grow it (/dev/shm out
of space -- a container with the default 64MB tmpfs hits this quickly, since
one 720x480 monitor with 10 buffers already asks for ~20MB and a 1080x720 one
asks for ~62MB), or mmap itself fails.

zmc's startup loop reacts by retrying:

    while (!monitor->connect() and !zm_terminate) {
      Warning("Couldn't connect to monitor %d", monitor->Id());
      monitor->SetHeartbeatTime(std::chrono::system_clock::now());
      sleep(1);
    }

so the first thing it does after a failed connect is write through the null
pointer. zmc dies with SIGSEGV at address 0x80 instead of retrying, which
presents as a monitor that will not start and a capture daemon that keeps
crashing. The accessors on either side of these already guard with
`if (shared_data && shared_data->valid)`.

Reverting the guard fails the new test with SIGSEGV at zm_monitor_shm.cpp:66,
and restoring it passes. Same fix as release-1.38's a550545e4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBHBB95RBX7D9p8ge2WDZb
2026-09-22 09:57:19 -04:00
Isaac ConnorandClaude Opus 5 428c048bac feat: measure the audio level on demand, with a meter in the editor
Reverts the previous commit's always-on measurement. Decoding audio for every
monitor that has it spends CPU on a number almost nothing reads, which is the
wrong trade even though it did solve the chicken and egg of picking a
threshold without ever seeing a level.

Measure when something is actually going to use the reading instead:

 - AudioDetection is on, as before, so nothing changes for a monitor that
   scores on audio; or
 - somebody asked. SharedData gains audio_level_until, a wall clock second
   the capture thread keeps measuring up to. The monitor editor's new level
   meter pushes it forward while it is on screen and the measurement lapses a
   few seconds after the page is left, so nothing has to send a stop and a
   crashed browser cannot leave a monitor decoding forever.

When the reading stops being wanted the decoder is released and the published
level and peak are cleared, so a stale number is not left looking current and
an old peak does not land on the next frame row written.

audio_level_until is carved out of analysis_pad rather than appended, so
SharedData stays 888 bytes and no existing offset moves; the static_asserts,
Memory.pm and Monitor.php are updated together and all three now agree the
field is at +880.

The meter itself is on the audio settings, shown whether or not
AudioDetection is checked, because the level is what you need in order to
choose a threshold. It draws the threshold currently in the input as a mark on
the bar so a reading can be judged against it before saving, and a monitor
whose zmc is not running reads "no reading" rather than a confident 0, which
would be indistinguishable from silence.

Frames.AudioLevel is therefore 0 again on monitors that do not score on audio.
That is what the graph already treats as "no audio data", so it draws no line
rather than a flat one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:44 -05:00
Isaac ConnorandClaude Opus 5 4619258293 feat: measure the audio level whatever AudioDetection is set to
Gating the measurement on AudioDetection made the graph useless for the job
it is most wanted for. AudioThreshold is a per-device number -- the floor on
one camera's mic is nothing like another's -- so it has to be measured before
it can be set, but nothing was measured until it was already set. Enabling
detection with a guessed threshold to find out what the real one should be is
backwards.

The level is now read for every monitor with decodable audio.  AudioDetection
governs only whether crossing the threshold contributes a score, which is
what the setting is named for. shared_data->audio_alarm stays 0 when it is
off, so nothing downstream changes for a monitor that does not want audio
alarms.

Nothing here depends on Analysing either. The measurement is in
Monitor::Capture, which runs on whatever Analysing is set to, and frame rows
come from Event::AddFrame, which a continuously recording monitor reaches
through the RECORDING_ALWAYS path with motion detection off. So a monitor
that only records continuously still gets levels on its rows.

Since Monitor::Capture retries Open on every audio packet until it succeeds,
and that now happens for every monitor with audio rather than the handful with
detection on, AudioDetector remembers a codec it has already failed to find a
decoder for. Without it a stream ZoneMinder cannot decode logs a warning at
the audio packet rate for as long as the monitor runs. A reconnect bringing a
different codec is still tried.

The cost is one audio decode per monitor with audio, where before it was one
per monitor with detection enabled. That is small next to the video path, but
it is not nothing on a box with many cameras.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:44 -05:00
Isaac ConnorandClaude Opus 5 4ea307a1cf feat: graph motion score and audio level under the event video
The cue strip under the video showed one flat red band per alarm period. It
tried to encode the score as a bar height, 'height: '+frame.Score+'px', but
never could: the frames ajax did not return Score, so every bar got
'height: undefinedpx' and fell back to the stylesheet's height: 100%. So the
motion level it looks like it is drawing has never actually been drawn.

Replace it with a line graph of both series over the length of the event. The
alarm periods stay, as a pale wash behind the lines, so nothing that was
readable before is lost.

The two series do not share a vertical scale. Audio level is 0-100 by
construction, but a motion score is a sum over zones with no upper bound, so
pinning both to 0-100 would flatten the audio line against the floor on any
event scoring above 100. Each is scaled to its own maximum, and hovering reads
out the exact values, which is what the numbers are wanted for. An event whose
rows are all zero -- recorded before the column existed, or by a monitor with
AudioDetection off -- draws no audio line at all, rather than a flat line
claiming silence was measured.

The readout goes into the existing #indicator, which already tracks the mouse
across the whole progress bar, instead of a second tooltip competing for the
same pixels.

The geometry lives in web/js/LevelGraph.js so it can be tested without a DOM,
and so the polyline and the hover readout cannot disagree about where a given
second sits. The bar grows from 1.25em to fit the graph, which needed two
consequential CSS changes: #indicator now spans the taller bar, and
.progressBox drops from 0.66 opacity to 0.25, because at full strength the
played part of the graph is unreadable behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:44 -05:00
Isaac ConnorandClaude Opus 5 33661907d1 feat: persist the peak audio level on each frame row
zm_update-1.39.31.sql gave monitors audio detection, but the level only ever
existed in shared memory, so it was gone the moment the frame passed and there
was nothing for the event view to plot. Add Frames.AudioLevel next to Score, on
the same 0-100 dBFS-derived scale the threshold uses.

What is stored is the peak since the previous row, not the level at the instant
the row was written. Frames rows are written well below the capture rate --
only alarm, bulk and score-increasing frames get one -- so sampling at write
time would drop exactly the short loud noises worth seeing on a timeline.
AudioDetector accumulates the peak as it decodes and Event::AddFrame takes it
where the row is built, which clears it so each row covers its own interval.
The Event constructor takes and discards it once, otherwise an event's first
row reports the loudest moment since the previous event ended.

This needs no shared memory change: zma is now an offline re-analysis tool and
the live analysis runs in a thread of zmc, alongside the capture thread that
runs the decoder, so the peak can stay in the AudioDetector. SharedData keeps
its documented 888-byte layout and its fixed offsets.

The frames ajax returns the column, and Score with it. elements in
web/ajax/status.php is a whitelist that never listed Score, which is why the
event view's cue strip has been reading an undefined Score off every frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 40c4cd3c5d fix: run reverse playback at the rate the user picked
changeRate's reverse branch did not use the selected rate as the reverse
speed. It looked it up as rates[rates.indexOf(-rate)-1]/100, stepping one
entry further down the shared rate list, so every reverse rate ran a notch
too slow: -1/2x played at 1/4x and -16x at 10x. -1/4x was worse than slow,
because one step below 25 in that list is 0, so revSpeed came out 0 and the
video sat still while the ui claimed it was rewinding.

The rate the user picked is the speed, so use it.

Leaving reverse through the dropdown also leaked the rewind interval, which
only pauseClicked and vjsPlay ever stopped. Picking a forward rate after a
reverse one left it running, so it went on dragging currentTime backwards and
resetting playbackRate to 0 on every tick while the player was supposedly
running forwards. The teardown is now stopRewind(), split out of
stopFastRev() because stopFastRev rewrites the rate select to 1x, which would
undo the choice changeRate is in the middle of applying.

stopFastRev no longer reads the rate back out of the player to decide what to
put in the select and the cookie, for the same reason streamFastFwd stopped
doing it: videojs can defer the set until the tech is ready, so the getter
still answers with the rate we just left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 ef361dc3ab fix: stop the event playback rate buttons stepping off the end of the rate list
Clicking fast forward once too many at 16x threw out of video.js:

  TypeError: HTMLMediaElement.playbackRate setter: Value being assigned is
  not a finite floating-point value.
    playbackRate@video.min.js
    streamFastFwd@..._views_js_event-....js

streamFastFwd stepped the shared rate list by indexing it directly,
rates[rates.indexOf(current)+1]. At the top of the list that is rates[15],
undefined, and undefined/100 is NaN, which Firefox refuses outright.

The guard meant to prevent this ran after the assignment rather than before
it, and read the rate back from the player to decide, so it could only
disable the button once the bad value had already been sent. It was reachable
in normal use because streamPlay() re-enables the button whatever rate we are
at, so play-then-fast-forward at 16x throws every time; picking 16x from the
rate dropdown gets there too, since changeRate does not touch button state.

indexOf also answers -1 for a rate that is not in the list, and -1+1 indexes
rates[0], which is -1600: stepping forwards from an unlisted rate asked for
16x reverse.

streamFastRev had the same fault at the other end. rates[0-1] is undefined, so
revSpeed became NaN and every tick of the rewind interval then handed
currentTime a NaN.

Both now go through stepRate, which snaps an unlisted rate to the nearest
listed one and returns null rather than walking off either end, so the caller
disables the button and leaves the player alone instead of assigning
something it cannot use. streamFastFwd also sets the dropdown and cookie from
the rate it just asked for rather than reading it back, because the stack in
the report shows videojs deferring the set until the tech is ready, at which
point the getter still answers with the old rate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:43 -05:00
Isaac ConnorandClaude Opus 5 f36645dfd9 fix: point the audioMotion-analyzer install instructions at the ES module
The library is AGPL-3.0-or-later so it is not shipped, and the admin installs
it at skins/<skin>/assets/audioMotion-analyzer/src/audioMotion-analyzer.js.
The instructions for doing that had drifted from what the code loads:

- help.txt and the OPTIONS_WHATTODISPLAY help gave the bare
  https://cdn.jsdelivr.net/npm/audiomotion-analyzer@X.X.X URL, which resolves
  to the package's "main" entry, the minified UMD bundle dist/index.js. The
  install path is the package's src/ ES module, so the URL needs the explicit
  /src/audioMotion-analyzer.js suffix. Same for the download links in the
  AudioMotionVersionNotInstalled and AudioMotionVersionWrongVersion messages.
- The install path was written as /skins/MySkin/..., a placeholder that does
  not correspond to any skin.
- RequiresAudioMotionEnabled named only the file, not where it goes.
- assets/version documented every other asset in that directory but not this
  one, leaving no explanation for the otherwise empty directory.

help.txt no longer restates the required version, so 4.5.4 stays declared only
by SUPPORTED_AUDIO_MOTION_ANALYZER_VERSION as intended, and assets/version
points at that constant rather than duplicating it.

Add tests/js/audiomotion-paths.test.js to hold the PHP feature probe, the
dynamic import, help.txt and both lang catalogues to the same path, the same
download URL and the same version.

Also gitignore the installed library so a local install is not committed back
into a GPL-2.0 tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JpiSWBmtQkR5bcgpHWY4ME
2026-09-20 18:17:43 -05:00
Claude 21042ba2f7 fix: send audio HELLO first and keep rtsp sessions across a zmc restart
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
2026-09-20 00:36:35 +00:00
Claude 281a7d967e fix: harden stream socket transport and protocol from PR review
Address several stream socket review findings on the transport, its
consumer client and the wire protocol:

- ParseAllowedUids rejects negative, out-of-range and non-round-tripping
  uids instead of wrapping or truncating them (e.g. 2^32 no longer
  becomes uid 0).
- StreamSocketClient backs off after a connection the producer closes
  before any message, so a rejected consumer (uid allow-list, client
  limit) no longer busy-loops; a rejection is not reported as a
  disconnect.
- SendMedia drops packets for a stream that has no announced HELLO, and
  ClearAudioParams forgets a previously announced audio stream (bumping
  the generation and re-issuing the surviving video HELLO), so a stale
  audio HELLO is never replayed and media never precedes its HELLO.
- Header pts_us is encoded as signed (two's-complement) microseconds so
  negative and AV_NOPTS_VALUE timestamps survive the wire; the dump tool
  decodes it as signed and tracks sequence gaps per generation so a
  generation reset is not mistaken for packet loss.

Tests cover the uid rejections, the audio HELLO clearing and media
guard, the connection-rejection backoff, and signed pts round-trips.

refs #5143

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4UcdJLt1bxwdpcigGxZRD
2026-09-19 23:00:03 +00:00
Steve GilvarryandClaude Fable 5.1 0e88b390c3 test: add a stream socket cost benchmark
A hidden Catch2 case ([.benchmark], run with ./tests "[benchmark]") that
pushes 1000 synthetic H.264-like packets (150 KB keyframe every 50, 15 KB
deltas) through a StreamSocket at 250 packets a second with 0, 1 and 8
consumers, and prints per scenario: SendMedia wall time on the producer
thread (p50/p99/max), CPU per packet for the producer, the listener
thread and an average consumer, and how many packets each consumer
received. Producer CPU is sampled around each SendMedia with the clock
read overhead calibrated out; the listener figure is the process CPU
left after subtracting the producer thread and the consumers.

Run on a 4-core VM after the unlocked drain change: about 1.3 us per
SendMedia with no consumer, 16 us with one, 18 to 24 us with eight,
and every consumer receives every packet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 05:51:32 +10:00
Steve GilvarryandClaude Fable 5.1 c11e484eca test: use a per-process socket path in the stream socket tests
Both suites bound a fixed path under /tmp, so two test binaries running
at once (parallel ctest, a developer and CI on one box) would unlink
each other's listener. Include the pid in the path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 05:12:43 +10:00
Steve GilvarryandClaude Fable 5.1 65fdfaecae fix: omit oversized extradata from the stream socket HELLO instead of truncating
BuildHello cast extradata_size to the u16 TLV length, so extradata over
64 KiB was sent truncated with a length that happened to match, and a
consumer would try to use a cut-off parameter set. Parameter sets are a
few hundred bytes, so anything that large is unusable anyway: warn and
leave the tag out, which keeps the HELLO valid. Name the TLV limit and
use it for the string clamp too.

Tests: extradata one byte over the limit is omitted and the HELLO still
parses; exactly the limit travels intact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 05:12:42 +10:00
Steve GilvarryandClaude Fable 5.1 84b1c06430 fix: drop the cached stream socket keyframe when the capture source closes
The keyframe cached for late joiners survived Monitor::Close(). A
consumer connecting while the camera reconnected was primed with a
keyframe from the previous capture session, whose pts can be ahead of
what the new session produces, and with identical stream parameters
there is no generation bump to warn it. Add StreamSocket::InvalidateKeyframe()
and call it from Close(); the next keyframe from the new session fills
the cache again.

Tests: after InvalidateKeyframe() a new consumer gets HELLO and then the
next live packet, with no KEYFRAME replay in between.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 05:12:42 +10:00
Steve GilvarryandClaude Fable 5.1 d5d997da41 feat: deliver EVENT frames through StreamSocketClient
The consumer class dispatched HELLO, MEDIA, KEYFRAME, STATS and BYE but
had no case for the EVENT type added for the monitor lifecycle channel,
so every event fell into the unknown-type branch and was dropped at
debug level 2. Add an on_event callback that receives the parsed
MonitorEvent together with the header (event sequence and media
generation), and reject malformed payloads with a warning like HELLO.

Tests: a client connected to a StreamSocket receives the cached snapshot
on connect and a broadcast state_changed, with the codes, state names,
health code, message and wall clock intact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 05:12:42 +10:00
SteveGilvarryandClaude Fable 5.1 f69700eb6a feat: serve monitor lifecycle events on the stream socket
Add SendMonitorEvent() to broadcast an EVENT frame to every connected
consumer, framed with a per-monitor event sequence (independent of the
media sequences, so it is not reset by a media generation bump) and the
current media generation for correlation. Events are control messages and
are never dropped from a client queue; the sequence still advances when no
consumer is connected, so a late joiner sees the loss as a gap.

Add SetSnapshotEvent() to cache the current-status snapshot replayed to
each new consumer on connect, the events analogue of the cached keyframe.
AcceptClient now enqueues HELLO(s), the snapshot, then the keyframe.

Tests: a broadcast EVENT round-trips with the right type/stream/sequence;
the event sequence advances across a clientless gap; the snapshot is
replayed after HELLO on connect. Ran ./tests/tests '[stream_socket]':
130 assertions in 11 cases pass.

refs #2875

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 05:02:24 +10:00
SteveGilvarryandClaude Fable 5.1 2964b8ed59 feat: add EVENT frame to stream socket wire protocol
Add MessageType::Event (0x06) and StreamId::Monitor (0x02) to the v1
stream socket protocol, with an event-code + TLV payload (BuildEvent/
ParseEvent) for the monitor lifecycle channel. Event codes cover the
capture-fault edges (connection/prime/capture failed and restored), a
state_changed transition, and a snapshot sent on consumer connect. TLV
tags carry wall-clock microseconds, a human-readable message, current and
previous state id, an errno/ffmpeg detail code, a state name, and a health
code that a snapshot uses to report the active fault.

The framing is unchanged and length-prefixed, so the new type is
forward-compatible: a pre-event consumer skips it by length, a pre-event
zmc never emits one. No protocol version bump.

Adds round-trip, unknown-tag-skip, and truncation tests. Ran
./tests/tests 'stream_socket::*': 93 assertions in 14 cases pass.

refs #2875

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 05:02:24 +10:00
SteveGilvarryandClaude Fable 5 d0c0a64754 feat: add StreamSocketClient consumer class
Reader side of the stream socket protocol, for zm_rtsp_server and any
in-tree consumer: connects to PATH_SOCKS/stream_{id}.sock with 1s retry,
reads exact-size length-prefixed messages into one reused buffer (no
per-read allocation churn, no resync scanning - contrast the FIFO
reader's 4KB chunking and memmem hunting), validates headers and
dispatches HELLO/MEDIA/KEYFRAME/STATS/BYE through callbacks on the
reader thread. Reconnects automatically on EOF or protocol error; a
fresh HELLO arrives after every reconnect. An adopt-fd constructor
supports tests and single-shot uses.

Tests: 5 new Catch2 cases - end-to-end against a real StreamSocket
(HELLO fields, media payload integrity), BYE + reconnect across a
server restart, fragmented byte-stream delivery via socketpair,
malformed-header disconnect, unknown-message-type skip. Full suite:
103/103 pass via ctest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-16 05:00:44 +10:00
SteveGilvarryandClaude Fable 5 b46be59622 feat: add StreamSocket unix-socket media server class
Per-monitor media stream server for the wire protocol added previously:
a poll()-driven listener thread serving multiple consumers at once over
PATH_SOCKS/stream_{monitor_id}.sock.

Memory and blocking behaviour:
- each message is serialized once and shared across all client queues;
  media payloads are reference-counted via av_packet_clone, no copies
- header and payload are written with writev, never concatenated
- the producer (capture thread) never blocks: per-client queues are
  bounded by bytes and message count; on overflow the oldest non-control
  messages are dropped for that client only, observable as sequence gaps
  and in STATS; clients making no progress are disconnected
- the latest keyframe access unit is cached (refcount only) and replayed
  to late joiners after HELLO for immediate first-frame rendering

Parameter changes (SetVideoParams/SetAudioParams) bump the generation,
reset sequences and rebroadcast HELLO. Peers are checked via SO_PEERCRED
against ZM_STREAM_SOCKET_ALLOWED_UIDS; sockets are chmod 0660 with group
ZM_STREAM_SOCKET_GROUP. Stop() sends BYE so consumers can distinguish
shutdown from failure.

Tests: 8 new Catch2 test cases (lifecycle/permissions, HELLO-first
ordering, late-joiner keyframe replay, queue overflow with sequence-gap
and STATS accounting, stalled-vs-live client isolation, generation bump,
BYE on stop, allowed-uids parsing). Full suite: 98/98 pass via ctest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-16 05:00:44 +10:00
SteveGilvarryandClaude Fable 5 c4a8d4ddf6 feat: add stream socket wire protocol encoder/decoder
First part of the monitor stream socket series: a length-prefixed binary
protocol (version 1) intended to replace the per-monitor media FIFO text
framing. Pure encode/decode functions with no I/O.

- 24-byte fixed header (little-endian): length, version, type, stream,
  flags, sequence, generation, pts_us
- Message types HELLO/MEDIA/KEYFRAME/STATS/BYE
- HELLO payload is a TLV list built from AVCodecParameters, carrying
  codec id, extradata (SPS/PPS/VPS, AAC AudioSpecificConfig, AV1
  sequence header), dimensions, frame rate, sample rate, channels,
  profile and level; unknown tags are skipped by parsers
- STATS payload carries per-client sent/dropped counters

Tests: 8 new Catch2 test cases (header roundtrip and boundary/reject
cases, HELLO video/audio/no-extradata roundtrips, unknown-tag skip,
malformed-TLV rejection, STATS roundtrip). Full suite: 90/90 pass
locally via ctest with BUILD_TEST_SUITE=ON.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-16 05:00:44 +10:00
Isaac ConnorandClaude Opus 5 599627a940 fix: stop the cycle timer leaking an interval on every restore fixes #5135
cycleStart() took a fresh setInterval id straight into cycleIntervalId without
clearing what was already there. Once overwritten the old id is unrecoverable,
so the orphan keeps calling nextCycleView every second and cyclePause() can only
ever stop the last one armed.

Several callers reach cycleStart() with no cyclePause() in between: the play
button, the are-you-still-watching modal closing, and startPage(). That last one
is the routine path - it runs from visibilitychange, from resume and from
pageshow, and a restore fires more than one of those, so a tab coming back while
cycling was active armed two. The monitor restart in the same function is
protected, since it nulls prevStateStarted on the way through, but the cycle
branch below it never cleared prevStateCycle.

Clear the interval at the top of cycleStart(), which makes every caller safe
whatever order they arrive in, and clear prevStateCycle in startPage() the way
prevStateStarted already is. cycle.js has the same shape in its own cycleStart()
behind a play button, so it gets the same guard.

Tests in tests/js/watch-cycle-interval.test.js drive watch.js under stubbed
timers and count what is left running: two starts leave one interval, a pause
after three starts leaves none, and a second startPage() does not re-arm. Three
of the four fail against the unfixed file.

Full JS suite passes, ESLint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM
2026-09-13 11:51:26 -04:00
Isaac ConnorandClaude Opus 5 da99928235 fix: revalidate on every resume rather than trusting a time window
The staleness window was unsound. calculateAuthHash() keys the hash to the
clock hour it was minted in and getAuthUser() accepts the last
ZM_AUTH_HASH_TTL hourly buckets, so a hash dies at the top of an hour rather
than at some age. generateAuthHash() then serves the cached one until it is
half a TTL old, so what arrives can already be nearly spent: on the defaults a
hash minted at 10:59 is still handed out at 11:58 and is refused at 12:00. A
client stamping that arrival as fresh for an hour skips the probe until 12:58
and restarts its streams on a dead hash - the exact failure this was written to
prevent. No fixed window is safe, because the remaining life of a hash we hold
can be anything down to zero, and AUTH_STALE_MS also ignored the configured
ZM_AUTH_HASH_TTL.

So drop AUTH_STALE_MS, authIsStale() and authFreshAt, and have whenAuthFresh()
revalidate. The one case that can still skip the probe is having no hash at all
- authentication off, or a relay form that does not use one - where there is
nothing that can expire and nothing a probe would report. revalidateAuth()
already shares one request between concurrent callers, so a resume that wakes
several of these still costs a single probe, and that is what the montage code
did unconditionally before any of this.

refreshTablesPendingVisibility() now returns as soon as it finds nothing was
deferred. It is bound on every classic page including the unauthenticated ones,
and the version before this ran the whole auth path on an empty queue, so
merely becoming visible could fire a probe with no work behind it.

Tests: two authIsStale cases removed with the function, two whenAuthFresh cases
added - a probe is sent and the callback held until it answers, and no probe is
sent when there is no hash. Reintroducing a fast path fails the first. Full JS
suite green, ESLint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM
2026-09-13 10:37:02 -04:00
Isaac ConnorandClaude Opus 5 4c878eeebe fix: send the auth revalidation probe without a credential
The probe went out as zmAuth.appendTo(...), carrying the very hash it existed to
replace. zm_authenticate_request() resolves a request against exactly one source:
a non-empty auth= in the URL enters the ZM_AUTH_HASH_LOGINS branch (on by
default), and when getAuthUser() rejects it the chain has already been taken, so
the userFromSession() arm below it never runs. A live session cookie then
authenticates as nobody.

Past ZM_AUTH_HASH_TTL - a tab hidden longer than two hours on the defaults, which
is exactly the case this change is for - the probe was therefore the one request
guaranteed to fail, and its failure is read as 'login', so the user was bounced
to the login page with a perfectly good session. That is worse than the 403s in
the log this set out to remove.

Send the probe bare. The session cookie is what answers, which is the question
being asked: who am I, and what is my current hash?

That also makes the failure handling mean what its comment claimed. A rejection
now really is a dead session rather than a dead hash, so redirecting to login on
it is right - and both 401 and 403 reach it, which the comment now says.

Also correct the AUTH_STALE_MS comment: authIsStale() is a strict comparison, so
a credential confirmed exactly AUTH_STALE_MS ago is still fresh, as the test
asserts.

Tests: tests/js/auth-helpers.test.js, 51 passed (4 new for revalidateAuth,
covering the bare probe, shared in-flight request, callbacks surviving a
transient failure, and login on a rejected session). Reverting the probe to the
credentialed form fails two of them.

refs #5093

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nr76CednxtDt2nPuq6WrbL
2026-09-13 00:03:13 -04:00
Isaac ConnorandClaude Opus 5 1d01c966d4 refactor: track credential age instead of hidden time, fold montage in
whenAuthFresh() gated on how long the tab had been hidden, which only covers
one of the ways the page stops hearing about the credential. montage's idle
timeout is another: hitting ZM_WEB_VIEWING_TIMEOUT stops every monitor, and
their status polls with them, while the tab stays visible the whole time. The
Are You Still Watching modal can then sit there for hours, so the auth hash
baked into the monitor srcs is just as dead as after an overnight sleep, and
a hidden-time gate would wave it straight through.

Track the age of the credential itself instead. ZMAuth.update() stamps it
whenever a reply carries auth_relay or auth - even when the value is
unchanged, since the server has still just confirmed it - and revalidateAuth()
stamps it too, so authentication being off doesn't leave every caller
revalidating once an hour forever. authHiddenTooLong(hiddenAt, now) becomes
authIsStale(freshAt, now); onAuthVisible() no longer keeps a timestamp.

That subsumes montage's refreshAuthAndStartMonitors(), which duplicated
revalidateAuth()'s navBar probe and fired a second one on every refocus.
Both call sites are now whenAuthFresh(startVisibleMonitors), which shares
the in-flight probe rather than racing it.

Tested: node tests/js/auth-helpers.test.js (47 passed), node
tests/js/table-helpers.test.js (8 passed), npx eslint on the changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkE45gkMjnkTiJiUySbe7V
2026-09-13 00:03:13 -04:00
Isaac ConnorandClaude Opus 5 c83a15e048 fix: refresh auth hash before restarting streams after a long tab hide
Hidden tabs have their timers throttled, and frozen/slept ones stopped
outright, so nothing refreshes the credential while the tab is in the
background. The server rotates the auth hash at half of AUTH_HASH_TTL
(generateAuthHash()), so the hash baked into a stream <img> src or a
deferred table url is usually dead by the time the tab is refocused. Every
restarted stream and table poll then fires a request that 403s and logs an
auth error before anything gets around to revalidating.

Record when the tab goes hidden and treat the credential as stale once more
than an hour has passed. Add whenAuthFresh(cb), which runs cb straight away
after a short alt-tab but queues it behind a revalidation after a long
sleep, so nothing makes an authenticated request on the expired hash.

revalidateAuth() now queues callbacks rather than dropping them when a probe
is already in flight, clears the hidden timestamp only once a reply actually
arrives, and still runs the queued callbacks on a transient failure - a
network blip is no reason to leave the page's streams stopped. A 401 still
redirects to login and drops the queue.

Gate the two visibility-driven callers on it: watch.js startPage() and
table-helpers.js refreshTablesPendingVisibility(). montage.js already
refreshes auth before restarting its monitors.

Tested: node tests/js/auth-helpers.test.js (48 passed), node
tests/js/table-helpers.test.js (8 passed), npx eslint on the changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkE45gkMjnkTiJiUySbe7V
2026-09-13 00:03:13 -04:00
Isaac ConnorandClaude Opus 5 1361f93804 fix: send the initial mode=paused CMD_PLAY that streamCommand was dropping
select_zms() has three ways out, and two of them send a stream command before
the tail of the function sets started. streamCommand() drops anything sent while
that is false, so such a branch reports success having sent nothing and the
picture sits on its last keepalive frame.

The resume branch was fixed on this branch already. The branch below it, for a
page rendered with mode=paused, has the same shape and was missed. With auth on
it needed a still-valid hash to reach, so it was intermittent; with auth off,
where there is no hash that can go stale, the srcAuthCurrent change on this
branch makes it the path every initial load takes. Set started there too.

Add tests/js/monitorstream-resume.test.js, which asserts what reaches the wire
rather than which branch ran: the resume and initial-paused paths each send
exactly one CMD_PLAY on the connkey they are supposed to address, resuming
leaves src and connkey alone, and a stale auth hash still rebuilds and quits the
process the old connkey addressed. Removing the one-line fix fails the
initial-paused case and leaves the other three passing.

Full JS suite passes, ESLint clean on both files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM
2026-09-12 15:51:14 -04:00
Isaac Connor 76002987f9 Merge pull request #5128 from connortechnology/5127-packetqueue-iterator-leak
fix: free the event start iterator when openEvent cannot lock its packet
2026-09-12 10:24:25 -04:00
Steve GilvarryandClaude Opus 5 74453bf783 test: guard the WS-Security test on WITH_GSOAP refs #4998
src/CMakeLists.txt wraps the gsoap sources in if(GSOAP_FOUND), so the
daemons build fine without gsoap. tests/ had no such guard, and
zm_onvif_wsse.cpp includes soapH.h and plugin/wsseapi.h unconditionally,
so BUILD_TEST_SUITE=ON plus no gsoap failed to compile:

  tests/zm_onvif_wsse.cpp:23:10: fatal error: 'soapH.h' file not found

gsoap was therefore optional for the binaries and mandatory for the test
suite. Nothing noticed because ci-cpp-tests.yml installs libgsoap-dev, so
the no-gsoap path is never exercised.

Guarded in the file rather than in CMake, matching what
zm_onvif_auth_error.cpp already does. zm_onvif_renewal.cpp deliberately
stays unguarded - it pulls in no gsoap headers and its four cases are
plain timing and formatting logic that run either way.

Verified on macOS: 144 cases with gsoap, 142 without, both clean builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5KL9Xbi7K5aGsauLtd8tG
2026-09-12 15:52:55 +10:00
Isaac ConnorandClaude Opus 5 0afa25f40e fix: finish the move to Catch2 v3 in the test suite
The tests target Catch2 v3 throughout: zm_catch2.h includes catch2/catch_all.hpp,
tests/CMakeLists.txt links Catch2::Catch2WithMain, and zm_config.cpp writes
Catch::Approx. None of those exist in v2. Two v2 spellings were left behind and
they broke the build against the version the suite actually requires.

zm_audio_detector.cpp used the unqualified Approx that v3 moved into the Catch
namespace, so four assertions failed to compile with "'Approx' was not declared
in this scope". Qualify them the way zm_config.cpp already does.

tests/main.cpp held nothing but #define CATCH_CONFIG_MAIN and the umbrella
include. In v2 that defined the entry point; in v3 the macro does nothing and
Catch2WithMain supplies main, so the file compiled to an empty translation unit.
Remove it and drop it from add_executable.

find_package(Catch2 REQUIRED) accepted a v2 install and left it to fail later as
compile errors. Ask for 3.

CI does not cover any of this because it builds with BUILD_TEST_SUITE=0.

Ran the full suite from the tests directory, where the font fixtures resolve:
144 test cases, 12480 assertions, all passing. ctest: 149/149.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:54:58 -04:00
Isaac ConnorandClaude Opus 5 99ae7b8e98 fix: free the event start iterator when openEvent cannot lock its packet fixes #5127
PacketQueue::get_event_start_packet_it() allocates a packetqueue_iterator and
registers it in PacketQueue::iterators. Every path out of Monitor::openEvent()
hands that pointer to the Event, which releases it in ~Event via free_it, except
the one that fails to lock the starting packet. That path returned nullptr and
left the iterator registered for the life of the process.

clearPackets() takes min_iterator_queue_index across all registered iterators and
stops removing at the first packet whose queue_index reaches it. An abandoned
iterator pins that to the front packet, so the trimmer removes nothing on every
invocation, and deletePacket() advances registered iterators onto the packet
after the one it removed, which drags the orphan onto each new front packet so it
can never age out. clear_packets_pending_ then latches true, downgrading the gate
so the futile scan re-runs on every video packet rather than only on keyframes.

The only removal path left is the emergency one-GOP eviction in queuePacket, so
the queue settles into refill a GOP, exceed max_video_packet_count, warn, evict a
GOP, repeat. It is pinned at its cap by construction, which is why the monitor
warns once per GOP at a constant rate for as long as zmc runs and no amount of
idle time or extra headroom recovers it.

Our trigger was an RTSP stream EOF mid-event during a reconnect on an ONVIF
camera. It is a race, so it leaks only when openEvent loses it, and each
occurrence leaks one more iterator.

Add tests covering both halves: that free_it takes an event start iterator back
out of the queue, and what an abandoned one does to trimming until it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 21:49:34 -04:00
Isaac ConnorandClaude Opus 5 ad70d5e80a feat: add an AlarmEnd trigger to monitor actions
Alarm turns a light on when motion starts; nothing turned it back off. The
existing EventEnd is not the answer: the alarm ends when the monitor leaves the
alert state, while the event carries on recording for the rest of its section,
which on a continuous-recording monitor is up to ten minutes later.

Analyse() leaves the alarm condition by three paths and all three fire it: the
normal ALERT to IDLE, a signal change, and the trigger being turned off. The
abnormal two matter most - a light switched on by an alarm must not stay on
because the camera lost signal.

That needs the firing to be paired, so alarm_actions_fired is set when Alarm
fires and cleared by EndAlarmActions. It guards both directions:

  - the three call sites can run back to back, so without it one alarm could
    fire AlarmEnd several times
  - signal loss and trigger off also run on monitors that never alarmed, and an
    unpaired AlarmEnd would switch a light off for an alarm that never happened
  - the ALERT to ALARM re-trigger inside one incident deliberately does not
    re-fire Alarm, so a single AlarmEnd still has to balance it

The migration MODIFYs the enum and appends the value, so stored rows keep their
meaning; appending to an enum does not renumber what is already there.

Tested: 8 assertions over the pairing rule, run standalone because the real
class needs a database and a camera - the ordinary pair, repeated calls firing
once, an unpaired call firing nothing, two alarms pairing independently, and
the re-trigger case. The action test now also requires every trigger name to
round trip through the TriggerOn enum, so an action saved by the editor cannot
load with a trigger the C++ does not recognise and fire at the wrong moment.
Build clean at 1.39.33, Perl 15 files / 249 assertions, ESLint 0 problems,
tests/js 154 assertions, php -l clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-11 20:13:03 -05:00
Isaac ConnorandClaude Opus 5 8d77f0f637 feat: deprecate the Remote/RTSP capture method in favour of Ffmpeg
ZoneMinder carries its own RTSP/RTP implementation - zm_rtsp.cpp,
zm_rtp_ctrl.cpp, zm_rtp_source.cpp, zm_rtp_data.cpp, zm_rtp.cpp and
zm_remote_camera_rtsp.cpp, about 66KB in total. It is hand-written parsing of
untrusted network input, which is the least rewarding kind of code to own, and
Ffmpeg already does the same job for more cameras and is maintained upstream.

This marks it deprecated. It does not remove anything and does not change how
any existing monitor captures.

The capture log now names the replacement instead of only saying the method is
going away:

  Monitor 7 (Driveway): the Remote/RTSP capture method is deprecated and will
  be removed. Change this monitor to Type 'Ffmpeg' with Source Path
  rtsp://10.0.0.5:554/live

Monitor::RtspUrlFromRemote builds that URL from the Host, Port, Path, User and
Pass the monitor already stores. It is pure so it can be tested without a
camera, and it is the piece a conversion migration will need when the code is
finally deleted.

The URL is passed through remove_authentication() before being logged, the
same helper zm_ffmpeg_camera.cpp uses. A deprecation notice that wrote camera
passwords into /var/log/zm would be a bad trade.

Credentials are percent-encoded. A password containing @ or : otherwise splits
the authority in the wrong place and yields a URL pointing at a different host,
which fails in a way that is hard to read.

In the editor the protocol is now labelled "RTSP (deprecated)" and selecting it
explains what to change to. Only the label moved: htmlSelect still emits
value="rtsp", so updateMethods and the stored value are untouched.

No auto-converting migration. Deprecating and removing are separate steps, and
silently rewriting a working monitor to Ffmpeg could break a camera that Ffmpeg
handles worse - which is the reason to warn a release ahead of removing.

Tested: 16 assertions over the URL builder, run standalone because ctest still
cannot build against the Catch2 v2 installed here - empty credentials producing
no dangling userinfo, user without password, @ : / ? # and space in passwords,
a username needing escaping, missing leading slash, empty path, absent port,
query strings surviving, and that the masked form drops the password while
keeping the host. Full build clean, Perl 15 files / 249 assertions, ESLint 0
problems, tests/js 154 assertions, php -l clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-11 20:13:02 -05:00
Isaac ConnorandClaude Opus 5 c7deb3faa8 feat: score monitors on how loud their audio is
ZoneMinder has carried audio for years without ever listening to it: the
packets go into the event and nothing reads them. AudioDetector decodes
them in the capture thread and reports a 0-100 level, which Analyse()
turns into score alongside motion, ONVIF and Amcrest.

The level is dBFS-derived, not a raw amplitude ratio. Linear RMS is
unusable as a setting: ordinary speech sits at 1-3% of full scale, so
every sound worth catching would be crammed into the bottom two points of
the range and no operator could tune it. Mapping -60..0 dBFS onto 0..100
puts speech around 43 instead.

Three columns on Monitors: AudioDetection to enable it, AudioThreshold
for the level to alarm at, and AudioAlarmScore for what it contributes.
AudioAlarmScore defaults to 9, matching what an ONVIF or Amcrest alarm
already adds. A threshold of 0 means off, so enabling detection without
choosing a threshold cannot alarm on silence -- a plain level >= threshold
test would alarm on every packet in that state.

The level and the alarm flag go in SharedData's two spare bytes, renamed
from reserved1/reserved2. Offsets and sizes are unchanged, so the
888-byte cross-process layout and the Memory.pm and Monitor.php offset
tables all still agree; the readers are renamed in the same commit so the
level is available to the web UI.

The decoder is opened lazily on the first audio packet rather than at
camera setup, so a monitor with detection off never carries one and a
stream that gains audio on reconnect still gets picked up. Scoring reads
the capture thread's most recent level rather than scoring per audio
packet, because the score belongs to a video frame and audio packets do
not arrive in step with them.

Tested: 242 assertions over the pure helpers - the RMS of both sample
formats, the dB scale's monotonicity and endpoints, full-scale clamping
of decoder overshoot, and the threshold-0 case. The repo's Catch2 harness
needs v3 and only v2 is installed here, so tests/zm_audio_detector.cpp
was compiled against a v2 shim to check it, and an identical set of
assertions was run standalone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
2026-09-11 20:13:02 -05:00
Isaac Connor eafdffcd95 feat: add per-monitor actions that drive a light or a speaker on alarm
A camera that detects motion should be able to sound a speaker, and the
speaker is rarely the camera: it is a separate device with its own address,
credentials, stream and Controls entry. Model it as a monitor and let a
monitor's alarm drive actions on other monitors.

Add Monitors.DeviceClass enum('Camera','Speaker'). This is what the device
is, as distinct from Type, which selects the capture backend - an IP speaker
still captures over Ffmpeg like any other RTSP device, so Type could not
carry the distinction.

Add the MonitorActions table: MonitorId is the monitor that triggers,
TargetMonitorId the device acted on, and the two are frequently different.
TriggerOn covers EventStart, EventEnd, Alarm and Manual.

Which actions a device is offered is decided by its Controls row - CanLight,
CanIndicatorLight, CanAudioPlay - so a device can only be asked to do what it
has been measured to do. The editor filters on this and the save path
re-checks it, because the request is not to be trusted.

Execution goes straight to the target's zmcontrol socket rather than forking
zmcontrol.pl per action: the daemon already accepts a line of JSON there, and
it is the same path the control panel uses. ActionCommandName maps the DB
enum onto a method name as a whitelist, so nothing out of the database
reaches the control daemon uninspected. Actions are fire-and-forget - a
speaker that is offline is logged and skipped, never allowed to hold up event
handling.

Alarm actions fire only on the genuine entry into alarm, not on the
ALERT->ALARM re-entry, which would re-sound a speaker within one incident.
EventEnd runs on the calling thread before the event is handed to the closing
thread, which does not capture `this`.

Manual actions appear as buttons on the watch page, and are the reason the
control panel is now shown for a monitor that has actions but no control of
its own. Firing one sends only the action id; the command and target are
rebuilt server side, and Control rights are required on the target device and
not merely on the monitor the action hangs off.

Also add --file to zmcontrol.pl, without which audioPlay could not be driven
from the command line. The web path was unaffected as it bypasses GetOptions.

Tests: tests/zm_monitor_action.cpp covers the command whitelist, the message
format (including that file id 0 is a real id and that a stale AudioFile is
never passed to a command that takes none), trigger names, and that every
value of the ActionType enum maps to a command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
(cherry picked from commit b561341e988af69c3a46db854da410309f7cfa82)
2026-09-11 20:13:02 -05:00
singhharsh1708 dbb6509efe test: read the bandwidth options the way the skin now derives them 2026-09-08 02:56:06 +05:30
Isaac Connor cddddd0b8f Merge pull request #5119 from ZoneMinder/5076-zone-object-size-followup
feat: derive zone detection settings from a drawn rectangle
2026-09-07 13:05:36 -04:00
Isaac Connor 83ba020cd8 Merge pull request #5076 from pliablepixels/zone-object-size-helper
feat: derive zone detection settings from a drawn rectangle
2026-09-07 12:16:26 -04:00
Isaac ConnorandClaude Opus 5 741de2d2e4 fix: finish the review items on the zone object size tool
The four points from the review on #5076 that the first round left open.

Persist a ratio as the number that will be read back. zone.php reloads these
cookies through validInt(), which strips everything that is not a digit, so 70.5
was stored and returned as 705 and 1e2 as 12, while the live calculation took
the same field through parseFloat and used it as typed. The ratio therefore
changed meaning on reload. Round and range-check before storing, and write the
result into the field so what is shown, what is calculated and what is stored
are one number.

Clear the tool when the page's Reset is pressed. Reset restores every field from
initialValues and redraws the polygon, so the measurement Undo would revert is
already gone, but undoSnapshot, lastBox, the highlighting and the rectangle all
survived it. The handler is wrapped rather than joined by a listener because
ours has to run first: resetChanges calls updateArea, which now re-derives.

Re-derive when the zone area changes. The thresholds are stored as percentages
of the zone, so moving, adding or removing a vertex changes what they mean in
pixels and the saved numbers stop describing the object that was measured.
updateArea is the one funnel those edits go through, so wrap it and re-run the
derivation from the box still on screen. apply() only reaches
updateAllPixelDisplays, so there is no way back into updateArea from there.

Draw with pointer events, and give the tool a keyboard-operable equivalent.
pointerdown/move/up with pointer capture replaces the mouse-only gesture, which
touch and pen could not drive at all, and touch-action is suppressed while armed
so the browser does not claim the drag for a scroll. pointercancel ends the drag
too, since a pointer taken away by the browser never sends pointerup. For the
keyboard there is now an object size in capture pixels beside the ratios: it
derives through the same path, draws the same box centred in the frame, and
raises the same filter warning. A finished drag writes its size back into those
fields, so the two ways in stay in step.

Also re-check measurability at pointerdown. The type listener added in the
first round covers the select, but applyPreset writes Type directly and fires
no change event, so a preset that makes the zone Inactive or Privacy left the
tool armed and apply() would re-enable the fields applyZoneType had disabled.

tests/js/zone-object-size.test.js covers the two new pure functions:
46 assertions pass. ESLint clean on the module and the test; php -l clean on
zone.php, zone.js.php and en_gb.php.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 12:14:44 -04:00
Isaac ConnorandClaude Opus 5 a0626ed66f test: own the SockAddr that newSockAddr returns
Both "newSockAddr from resolved addr" sections called
zm::SockAddr::newSockAddr and dropped the pointer. It hands ownership to the
caller -- Socket deletes the ones it holds in its destructor -- so each run
leaked one: 48 bytes for the AF_INET case and 240 for the AF_UNIX one, the
288 bytes in 2 allocations AddressSanitizer has been reporting on every run
of the suite.

Held in a unique_ptr rather than deleted at the end of the section, because a
failing REQUIRE throws and would walk straight past a delete there.

With this the whole suite is clean under AddressSanitizer for the first time:
12171 assertions in 133 cases with no report of any kind, and the [notCI]
socket cases pass under it too, 66 assertions in 5 cases. Normal build
unchanged at 12171 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
2026-09-07 11:24:16 -04:00
Isaac Connor b82ee8b7dd Merge branch 'master' into zone-object-size-helper 2026-09-07 10:20:10 -04:00
Isaac ConnorandClaude Opus 5 1e38c674fe fix: collapse the five Event_Summaries writes per event into one
Events_Hour, Events_Day, Events_Week and Events_Month each carried their
own update and delete trigger, and every one issued a separate UPDATE
against the same Event_Summaries row. A single Event delete therefore
locked that row five times: once from event_delete_trigger and once from
each bucket's cascade. That is the deadlock zmstats.pl hits when it bulk
deletes aged rows out of Events_Hour while zmc and zma are writing.

event_update_trigger and event_delete_trigger now modify the bucket tables
themselves and apply one consolidated UPDATE, using ROW_COUNT() after each
bucket statement to tell whether the event was still in that bucket so
aged-out events do not over-adjust the counters. Measured on a scratch
database, an Event delete goes from 5 Event_Summaries row updates to 1.

This also fixes a drift bug. The old event_update_trigger kept
ArchivedEventDiskSpace correct only in a branch that cannot be reached: it
sits under IF (NEW.Archived != OLD.Archived) and requires both to be
false. The branch that does run when an already-archived event grows
updated Events_Archived but not Event_Summaries, so the archived total
drifted for the life of the install. Reproduced on a scratch database:
after archiving a 250-byte event and growing it to 999,
ArchivedEventDiskSpace still read 100.

BEHAVIOUR CHANGE. A direct DELETE against a bucket table no longer adjusts
Event_Summaries at all, and that is how zmstats.pl prunes. zmstats.pl
already resyncs HourEvents/DayEvents/WeekEvents/MonthEvents and their disk
space columns from COUNT(*)/SUM(DiskSpace) on any pass where it pruned, so
those four pairs become eventually consistent within one
ZM_STATS_UPDATE_INTERVAL instead of exact at every instant. The Total and
Archived columns stay exact, because only the Events triggers touch them.
The zmstats.pl comments are updated to describe the new arrangement; its
code is unchanged.

Migration is db/zm_update-1.39.25.sql.in: drop the eight cascade triggers,
resync Event_Summaries from the events themselves so the new triggers start
from ground truth and the archived drift above is repaired, then source
db/triggers.sql. version.txt goes to 1.39.25. No schema change, so
zm_create.sql.in needs no edit -- it already sources triggers.sql.

Ported from the ai_server branch, where this migration sits at 1.39.7, a
number master passed long ago and an existing install would never run.
Repackaged above master's tip. The ai_server version also unwinds a
views-and-SWR experiment that only ever existed on that branch, which is
dropped here as a no-op on master, and carries an unrelated START_DELAY
change, which is not taken.

Tests: tests/perl/test_event_summaries_triggers.pl, 29 assertions against a
real server, skipped when no scratch database is configured. Verified they
fail on the current triggers, on exactly the two claims above: the archived
drift, and 5 row updates per delete. Also verified the migration repairs a
drifted install, is idempotent across a second run, and leaves a migrated
install with byte-identical triggers to a fresh one. Generated zmstats.pl
passes perl -Tc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
2026-09-05 12:23:39 -04:00
Isaac Connor 32e683a77f Merge remote-tracking branch 'upstream/master' 2026-09-04 08:45:13 -04:00