Commit Graph
15 Commits
Author SHA1 Message Date
Isaac Connor 4581360cf7 Merge pull request #5201 from nabbi/fix/b53-close-orphaned-events
fix-high: close events left open by a capture daemon that died
2026-10-13 18:17:38 -04:00
Isaac Connor bf447c96a2 Merge pull request #5247 from nabbi/fix/b11-reolink-uniview-json
fix-medium: load JSON through JSON::MaybeXS in Reolink_HTTP and Uniview, add a Reolink_HTTP Controls row
2026-10-13 18:16:40 -04:00
Nic BoetandClaude Opus 5.5 e472cd1533 fix: keep a name the user gave an event that zmwatch closes
Event::Close() renamed the event to <prefix><Id>(r) every time. That
was fine while only zmeventtool.pl close and zmaudit called it, by hand
or a day later; with zmwatch closing events left open by a zmc that
died, it would overwrite a name the user set while the event recorded.

Rename only when the name is still the placeholder 'New Event', as
zmc's own close does (~Event: Name = IF(Name='New Event', ...)). The
'Recovered.' note still marks the event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:37:20 -05:00
nicandClaude Opus 5.5 07179d7eae fix: set the WS-Discovery probe's interface before sending (#5257)
WSDiscovery::TransportUDP::send_multi opened its socket with PeerAddr,
which connects it, and called mcast_if afterwards. A connected UDP
socket keeps the route chosen at connect time, so on a host with more
than one network the Probe left through the default route whatever
interface was chosen: the web UI's ONVIF probe (zmonvif-probe.pl probe
<version> <interface>) found no camera on a second network.

Open the socket unconnected, set mcast_if first, and send to the group
with mcast_send. The destination is passed as a packed sockaddr_in:
given as "addr:port", mcast_send in IO::Socket::Multicast 1.12 (Debian
trixie) ends up with port 0 and the send fails with EINVAL. Without an
interface nothing changes: the probe goes out the default route as
before.

tests/perl/test_wsdiscovery_mcast_if.pl records the socket calls and
checks the order and the destination.

fixes #5256

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:26:40 -04:00
Nic BoetandClaude Opus 5.5 fb534131f8 fix: load JSON through JSON::MaybeXS in Reolink_HTTP, Uniview and jsonLoad
Reolink_HTTP.pm and Uniview.pm said `use JSON`, and
ZoneMinder::General::jsonLoad required JSON. The Debian and Ubuntu
packages depend on libjson-maybexs-perl, not libjson-perl, so on a
packaged install neither control module loads ("Can't locate JSON.pm in
@INC") and jsonLoad, which reads MacVendors.json for the network scan,
logs that error and returns nothing.

Use JSON::MaybeXS as the other control modules (AMLink, Dahua_RPC,
IPSpeaker) and the rest of ZoneMinder::General already do. encode_json
and decode_json behave the same; JSON::MaybeXS picks Cpanel::JSON::XS,
JSON::XS or the core JSON::PP.

tests/perl/test_json_maybexs.pl hides JSON.pm from @INC and loads both
modules and runs jsonLoad.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:24:24 -05:00
Nic BoetandClaude Opus 5.5 dc09122eb3 fix: close a recovered event from its row when it has no Frames rows
Event::Close() (zmeventtool.pl close) took the end of an event only from
its Frames rows and refused events that had none. zmc writes Frames rows
in batches and updates the event row's Length, Frames and scores with
each batch, so an event whose zmc was killed in its first seconds has no
Frames rows, and either side can be ahead of the other.

Take Length, Frames, AlarmFrames, TotScore and MaxScore as the larger of
the event row and the Frames rows; EndDateTime follows the Length that
was used. The name and Notes are written as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:36:14 -05:00
Isaac ConnorandClaude Opus 5.5 0e9f82513c fix: bind the FOSCAMR2C preset and require Control permission for PTZ refs GHSA-qcm7-vq92-f86f
FOSCAMR2C presetGoto and presetSet interpolated the preset into
"SELECT Label FROM ControlPresets WHERE Preset = $preset" and sent the selected
Label to the camera as name=. For presetGoto the preset came straight from the
web request, so any user who could see the monitor could run a SELECT against
the ZoneMinder database and have the value (the auth hash secret, password
hashes) delivered to the camera link, or use it as a boolean oracle.

- FOSCAMR2C: bind the preset in every statement, refuse a preset that is not a
  number, and look ControlPresets up by MonitorId as Dahua and PSIA do.
- buildControlCommand(): refuse a non-numeric presetGoto preset, and a control
  name that is not a plain method name. zmcontrol calls the name as a method and
  the options are split back out of the command string on spaces.
- zmcontrol.pl: refuse a command that is not a plain method name, so a qualified
  Package::sub name can't call a sub outside the control module.
- canView()/canEdit() with a monitor id returned only the monitor check and
  ignored the area, so canView('Control', $mid) passed for anyone who could see
  the monitor. Require the area permission as well; Monitors stays decided by
  visibleMonitor()/editableMonitor().

tests/php/test_control_command.php covers buildControlCommand() and the
monitor-scoped canView()/canEdit(); tests/perl/test_foscamr2c_preset_sql.pl
checks the statements FOSCAMR2C prepares and what it sends to the camera.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:21:32 -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 859af3a8f4 Merge pull request #5070 from AJ0070/fix/4423-zmcontrol-lock
fix: lock to stop a second zmcontrol server per monitor
2026-08-30 15:39:17 -04:00
Jash 386a12a174 fix: record the lock holder pid and separate held from unopenable refs #4423 2026-08-30 03:47:14 +05:30
Isaac ConnorandClaude Opus 5 94f41aab27 fix: write Config.DefaultValue in the same form as Config.Value
The C++ loader reads back only the rows where Value differs from DefaultValue
and takes compiled-in defaults for the rest, so the two columns have to be
written in the same form. Both writers passed Value through a boolean
conversion and DefaultValue through none: a boolean left alone was stored as
Value '1' against DefaultValue 'yes', never compared equal, and was read back
on every start. The filter did nothing for the 77 boolean rows.

Both columns now go through ConfigData::dbValue. It lives there because the
two writers - saveConfigToDB for an existing install and zmconfgen for the
zm_create.sql of a fresh one - have to agree, and drifting apart is what
caused this. An option with no default is stored as its type's empty value,
matching the empty string initialiseConfig already gives it.

The generated zm_create.sql now has Value equal to DefaultValue for all 259
rows, so a fresh install reads no Config rows at all. The generated
zm_config_defines.h is byte identical, confirming the compiled-in defaults
are unaffected by the representation change.

tests/perl/test_config_default_value.pl checks the invariant over every
option in ConfigData; it needs no database, since ConfigData does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
2026-08-29 14:01:25 -04:00
Jash d70fdeb0c9 fix: lock to stop a second zmcontrol server per monitor fixes #4423 2026-08-26 11:05:42 +05:30
Isaac ConnorandClaude Opus 5 bde61e7af6 fix: rotate logs on SIGWINCH instead of SIGHUP fixes #5063
SIGHUP means reload. zmc responds by closing its events, disconnecting the
camera and reconnecting; the perl daemons respond by exiting so zmdc restarts
them. zmdc.pl logrot hupped every managed process, so the nightly logrotate
run cost about 8 seconds of capture on a default install.

Rotating a log file only needs the daemon to drop its file handle, so use a
separate signal for it. SIGWINCH is otherwise unused, is ignored by default and
exists on every supported platform.

- Logger (C++) installs a SIGWINCH handler beside its USR1/USR2 handler. The
  handler only sets a flag; the next logPrint closes the file and the write
  reopens it at the original path. This covers every C++ binary without
  touching any daemon's main loop.
- Logger.pm registers WINCH alongside HUP in logSetSignal, which logInit
  already calls, so the scripts that install their own HUP handler still
  rotate.
- zmdc.pl logrot sends WINCH. The logrotate config is unchanged - it still
  calls zmpkg.pl logrot.

Filter.pm and FilterTerm.php justified MAX_EVENT_DAYS by events not outliving
the nightly HUP, which is no longer what bounds them; cite SectionLength.

Tests: tests/zm_logger_rotate.cpp and tests/perl/test_log_rotate_signal.pl both
log, rename the file out from under the process, confirm writes still land in
the renamed file, signal WINCH and confirm the original path is written again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDhTBPj9xEaT52pRmAufaP
2026-08-22 15:30:46 -04:00
Isaac ConnorandClaude Opus 5 6420571c04 fix: do not start or restart zmc for a monitor that has been deleted
zmwatch fetches its monitor list once at the top of a pass and then walks it.
Deleting a monitor mid-pass stops its zmc from the web ui, so by the time
zmwatch reaches that monitor its shared memory is gone, zmMemVerify fails and
zmwatch calls control('restart') from the now-stale list. zmc comes back for a
monitor that is marked Deleted, so no later pass ever looks at it again and
nothing stops it until zmpkg.pl restart. That is the orphaned zmc left behind
after deleting a monitor from the console.

Re-read Deleted from the database in ZoneMinder::Monitor::control() before
running a start or a restart, and skip the command if the monitor has been
deleted or the row is gone. control() is the single path every restart goes
through, so this covers any caller working from a list it fetched earlier.
Stopping is deliberately still allowed, since that is how an orphan gets
cleaned up.

Tests: tests/perl/test_monitor_control_deleted.pl stubs zmDbFetchOne and
runCommand and checks that start/restart still run for a live monitor, are
skipped for a deleted one and for one removed from the database, and that stop
is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDhTBPj9xEaT52pRmAufaP
2026-08-05 22:52:36 -04:00
Isaac ConnorandClaude Opus 4.7 f7a3263c52 perf: emit sargable range queries for Date/StartDate/EndDate filter terms
zmfilter and the web filter UI generated SQL like
  to_days(E.StartDateTime) = to_days('2026-05-06 09:42:56')
which prevents MySQL from using the StartDateTime index, forcing a
full table scan. With many filter daemons against a large Events
table this saturates mysqld and makes the system unresponsive.

Rewrite the SQL generation in ZoneMinder::Filter (Perl) and
ZM\FilterTerm (PHP) so Date/StartDate/EndDate attrs emit range
expressions against the underlying datetime column:
  E.StartDateTime >= '2026-05-06 00:00:00'
    AND E.StartDateTime < '2026-05-07 00:00:00'
Covers =, !=, >, >=, <, <=, IS, IS NOT, IN, NOT IN, and the
CURDATE()/NOW() values (which use INTERVAL 1 DAY for the upper
bound). EXPLAIN now reports type=range on Events_StartDateTime_idx
where it previously reported type=ALL.

CurrentDate (the constant left-hand expression to_days(NOW()))
keeps its existing form since it does not touch the indexed column.

Add Perl and PHP unit tests under tests/perl/ and tests/php/
exercising the generated SQL across operators.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:13:22 -04:00