Commit Graph
597 Commits
Author SHA1 Message Date
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 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 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 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
Isaac Connor a6bb811e97 feat: add audio playback control capability and an ONVIF IP speaker module
An IP speaker is controllable but has none of the capabilities the Controls
table models: nothing moves, focuses or lights up. It plays a sound file it
already holds, selected by a numeric id, and carries an output volume.

Add CanAudioPlay, MinAudioFile, MaxAudioFile and CanAudioVolume to Controls,
with migration zm_update-1.39.21.sql. CanAudioPlay renders one button per
sound id plus a stop button; MinAudioFile/MaxAudioFile bound that range;
CanAudioVolume renders the volume pair. The sound id travels in the command
name (audioPlay12), the way presets already do, because a control button has
no way to attach a separate parameter.

Add ZoneMinder::Control::IPSpeaker driving the device's own HTTP interface,
and a Controls entry for it. Playback goes over the vendor interface rather
than ONVIF because the ONVIF audio output service on this firmware only
describes the output and offers no way to start a stored file.

Measured against a device reporting ONVIF Manufacturer "IPSpeaker" and
firmware CS20-V3.3.45N:

- File ids fall in two windows, 10-14 built-in and 20-30 operator uploads.
  Anything else is refused with result -2, and an id in a window with no file
  uploaded with result -3, so valid_fileid refuses only the former: an empty
  slot is a legitimate id the operator may fill later.
- config=audio.set replaces the whole audio section, so a partial write
  reverts the microphone, codec list and echo-cancellation settings. Every
  volume change reads the section and echoes it back with the new level.
  audio.get also returns outmute, which audio.set rejects, so it is excluded
  from the field list.
- The volume reads back and round-trips, but the device's RTSP audio is a
  pre-volume tap of the playback signal: it is unchanged with outvolume at 0,
  so it cannot confirm the loudspeaker's acoustic output. CanAudioVolume is
  set on the strength of the setting persisting, and both the POD and the
  seed row say the acoustic effect is unconfirmed.

The seed row covers the built-in window only, because a fresh device has
nothing uploaded and the firmware refuses an empty slot.

Adding columns to Controls extends the 43 positional INSERTs in
db/controls.sql, which carry no column list and so must match the column
count exactly.

Tests: scripts/ZoneMinder/t/ip_speaker.t, 32 assertions covering file id
windows, volume clamping and stepping, request building and the whole-section
write. All pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvTCzCbvGt8xKQRNCSA7o8
(cherry picked from commit d07e552a3c58373f7f0ccebf9aa28adb4d51baa3)
2026-09-11 20:13:02 -05:00
Isaac ConnorandClaude Opus 5 11bade086e perf: drop unused and duplicate secondary indexes
An audit of every table in zm_create.sql.in against the queries that read it
turned up seven indexes that are either never used or duplicate another index.

Logs.TimeKey duplicates Logs_TimeKey_idx, which zm_create.sql.in creates three
lines below it and zm_update-1.31.11.sql adds to upgraded installs. Both have
existed on every install since, and every log INSERT maintains both. Logs is
the second highest insert-rate table in the schema.

Stats.MonitorId and Stats.ZoneId are never read. Every query against Stats is
anchored on EventId: the per-frame zone stats view in skins functions.php and
ajax/stats.php, the ZoneId filter term in FilterTerm.php and Filter.pm, the
deletes in Event.php, Event.pm and zmaudit.pl, and the orphan scan. EventId_ZoneId
serves all of them. A Stats row is written per frame per zone when
ZM_RECORD_EVENT_STATS is on, which is the same insert-rate argument as the
Frames indexes in the previous commit.

EncoderTemplates.Encoder is the leftmost prefix of Encoder_Name, and the RoleId
indexes on Role_Groups_Permissions and Role_Monitors_Permissions are the
leftmost prefix of the UNIQUE index beside them. Those tables are small and
rarely written, so this is tidying rather than a saving.

Monitor_Status_UpdatedOn_idx is dropped by zm_update-1.37.76.sql but was left in
zm_create.sql.in, so fresh installs have carried it and upgraded installs have
not. Removed from zm_create.sql.in and repeated in the migration so the two
agree from here on.

Stats.MonitorId and Stats.ZoneId are the only two with no other index covering
their column, so their drop is guarded on there being no foreign key on the
column. 1.37.31 drops the Stats foreign keys only when they are named
Stats_ibfk_1..4; on an install where they survived under another name the drop
would fail with errno 150 and abort the upgrade, so it skips with a message
instead. Verified by building that case and confirming the migration completes.

Tested against MySQL 8.4: the schema built from master's zm_create.sql.in, with
1.39.27 and 1.39.28 applied, is index for index identical to a fresh install
from the updated zm_create.sql.in - 123 indexes across 55 tables. Both
migrations verified idempotent, and all 29 foreign keys survive.

Not changed: AI_Detections carries three single-column indexes and has no
consumer anywhere in the tree yet, so there is nothing to judge them against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:51:49 -04:00
Isaac ConnorandClaude Opus 5 66881d8a76 perf: replace the three Frames secondary indexes with one on (EventId, FrameId)
Every query against Frames is anchored on EventId, and nearly all of them order
or range by FrameId within the event: the full-event reads in zm_eventstream.cpp
and zma.cpp, the prev/next bulk frame LIMIT 1 lookups in includes/Event.php and
ajax/status.php, zmfilter.pl's per-type scan, and the per-event aggregates in
Event.pm and zmaudit.pl.

A plain EventId index left all of those to a filesort. For the LIMIT 1 lookups
on the playback path that meant reading and sorting an entire event's frames to
return one row. Measured on a 200 event x 500 frame table, the prev-frame query
goes from "ref EventId_idx, rows 500, Using filesort" to "range
EventId_FrameId_idx, Using index condition, Backward index scan", the full-event
ORDER BY FrameId loses its filesort, and max(FrameId) WHERE EventId becomes
"Select tables optimized away".

Nothing filters or sorts on Type or TimeStamp without EventId, so neither index
was ever used for reading. Type is a three-value enum and cannot be selective in
any case. Both only cost insert time on the highest-insert-rate table in the
schema, plus space, plus work on every DELETE ... WHERE EventId.

The migration adds the composite index before dropping EventId_idx so that its
leftmost prefix covers EventId for any install still carrying the foreign key on
Frames.EventId that 1.35.11 added and 1.37.31 drops only when it is named
Frames_ibfk_1. Verified against a schema with that foreign key present: the drop
succeeds and the constraint survives. Verified idempotent by running it twice.

The index is left non-unique. FrameId is unique within an event, but a UNIQUE
constraint would turn a duplicate-id bug into frames dropped mid-recording
rather than a log line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:19:12 -04:00
Isaac Connor 531632be32 Merge branch 'master' into fix/3570-frameskip-column 2026-09-03 21:35:49 -04:00
singhharsh1708 fbabead1d3 fix: stop the Events_Lock comment from closing early in zm_create.sql 2026-09-02 20:36:42 +05:30
singhharsh1708 aad6c4bfcb refactor: retire the FrameSkip column refs #3570 2026-09-02 20:28:37 +05:30
Isaac Connor 81cfb438ca fix: widen Coords from tinytext to text on Zones and Maps
Coords holds a space-separated list of "x,y" points, written with two
decimal places by pointsToCoords()/mapCoords().  Since 1.39.2 the Zones
values are percentages rather than pixels, which costs up to 13 bytes per
point ("100.00,100.00") against the 9 a pixel pair used ("1920,1080").
tinytext caps at 255 bytes, so a zone of roughly 19 points or more
overflows and is silently truncated, leaving a corrupt polygon.  In pixel
form the same zone fit, so this only starts biting at the conversion.

Widen the column in 1.39.2 before its conversion runs, and again in a new
1.39.23 for installations that ran that conversion before the widening
existed - zmupdate.pl only applies files at or above the database version,
so those never revisit 1.39.2.  ALTER ... MODIFY to the same type is a
no-op, so both are safe to re-run.

Maps stores coordinates in the same format and is widened to match.  It
was added to zm_create.sql.in in 1.32 without a matching update script,
so installations older than that have never had the table; guard that
statement on the table existing rather than failing the whole migration.

Update zm_create.sql.in for both tables so a fresh install matches an
upgraded one, and bump version.txt so the new update script is reached.
2026-08-27 16:19:24 -04:00
Isaac Connor 022410c299 fix: stop abandoned events matching every DateTime window, and index the query
A DateTime lower bound was written as

  COALESCE(E.EndDateTime, '9999-12-31 23:59:59') >= T1

reading "an event that has not ended yet never ends". EndDateTime is NULL for
an event that is still recording, but also for one zmc was killed part way
through, and that stays NULL forever - so every abandoned event matched every
window from then on. Wrapping the column in a function also meant no index
could range over it: with the only MonitorId key being MonitorId alone, every
montage review request read all of that monitor's events and filtered them in
memory, however narrow the window.

Length tells the two cases apart. It is flushed during recording, so a live
event's effective end keeps advancing while an abandoned one's is frozen at
whatever was recorded. Use the same CASE the SELECT list in ajax/events.php
already uses, and bound it below by StartDateTime.

The lower bound now emits three conjuncts:

  E.StartDateTime >= DATE_SUB(T1, INTERVAL 1 DAY)
  AND (E.EndDateTime IS NULL OR E.EndDateTime >= T1)
  AND <effective end> >= T1

The floor bounds the scan for a window in the past and drops events abandoned
more than a day earlier - events do not outlive the nightly logrotate SIGHUP,
which stops and restarts them, so a day is comfortably beyond any real event.
The middle conjunct is implied by the third and exists only to give the
optimiser a second indexable handle: it is narrow exactly when the floor is
wide, so between them a window at either end of the retention period has
something cheap to range over. Verified the optimiser picks correctly for both,
unprompted. The third is the residual that gets the semantics right.

Add the two composite keys those conjuncts need. Two range columns cannot both
narrow one B-tree, and these are mirror images - EndDateTime >= T1 is
open-ended upwards, StartDateTime <= T2 downwards:

  Events_MonitorId_StartDateTime_idx (MonitorId, StartDateTime)
  Events_EndDateTime_MonitorId_idx   (EndDateTime, MonitorId)

MonitorId leads the first because it is the equality; past a range column later
columns can no longer narrow the scan, and (StartDateTime, MonitorId) measured
3.5x slower for the same rows. EndDateTime leads the second so it also covers
zmaudit's hunt for events that were never closed, which has no monitor to scope
it - 9 rows with a key against a 22,948 row table scan without.

Both replacements are added before the keys they supersede are dropped:

  Events_MonitorId_idx (MonitorId) is now a leftmost prefix of the new key.
  Events_EndDateTime_DiskSpace (EndDateTime, DiskSpace) existed for the scan
  that hunted events with no DiskSpace set; DiskSpace is set when the event is
  finalised in C++, so nothing scans for it any more.

Net index count on Events is unchanged.

Measured on a 7,167 event monitor. Default one hour window: 7,055 rows
examined / 25.4ms -> 173 rows / 4ms. Window scrubbed a week back, which
persists in the zmFilter_StartDateTime cookie: 6,995 rows / 28ms -> 19 rows /
0.15ms.

Adds migration db/zm_update-1.39.22.sql. Extends t/filter_sql.t; the PHP and Perl were
checked to emit identical SQL, and the semantics checked against a table of
seven event shapes - abandoned long ago, abandoned recently, overlapping,
inside, before, still recording, and still recording for 17 hours.
2026-08-19 22:06:21 -05:00
Isaac Connor ad1c01a502 feat: add the Events_Lock table and ZM_FILTER_LOCK_TIMEOUT
Events_Lock holds advisory locks over events, so that two filters do not work
on the same event at the same time. Claiming an event is a single autocommitted
INSERT of one row, which means no InnoDB lock is held while the filter actually
works on the event.

No foreign key to Events on purpose: it would make every event delete take a
lock in this table, which is the coupling this exists to avoid. Rows left
behind for deleted events are harmless and expire.

ZM_FILTER_LOCK_TIMEOUT (default 3600) is how long a claim lasts. A filter that
is killed part way through an event cannot release anything, so claims have to
expire on their own. It needs to be longer than the slowest thing a filter does
to a single event, which is why it is configurable rather than fixed.

Adds migration db/zm_update-1.39.21.sql.
2026-08-19 22:06:02 -05:00
Isaac ConnorandClaude Opus 5 cd88b741d5 Move the Controls seed rows to db/controls.sql, add the IP5M-1190EW
The 49 control protocol definitions were sitting inline in
zm_create.sql.in, which makes adding a model-specific entry a scroll
through a wall of positional INSERTs.  Move them to db/controls.sql and
pull them in with

  source @PKGDATADIR@/db/controls.sql

which is how User_Preferences.sql, manufacturers.sql, models.sql,
triggers.sql, Object_Types.sql, AI_Models.sql and coco_dataset.sql are
already handled, and the existing install(FILES ${dbfileslist}) glob puts
the new file where that path resolves to.  The 49 rows move across
byte-identical.

The new entry is the Amcrest IP5M-1190EW, verified against firmware
2.810.00AC004.0.R.  The generic 'Amcrest HTTP API' entry advertises zoom
and continuous zoom and no presets, which is backwards for this model:
pan, tilt, the diagonals and continuous move all physically move it, arg2
really is the speed, and GotoPreset works, while zoom, focus and iris do
not.  ZoomTele, ZoomWide, FocusNear and FocusFar answer OK and do
nothing; autoFocus, getFocusStatus, IrisLarge and AutoPanOn 400.
PositionABS answers OK and is inert, so CanMoveAbs and CanMoveMap stay 0.
The white light exists in Lighting_V2 but is not drivable, so CanLight
stays 0.

Worth recording for anyone rechecking this: ptz.cgi?action=getStatus on
that firmware always reports Postion=0/16.65 MoveStatus=Idle no matter
what the camera is doing, so status cannot tell you whether a move
worked.  All of the above was confirmed by diffing snapshots.

zm_update-1.39.20.sql carries the same row to existing installs, guarded
with NOT EXISTS so it can be re-run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzHrvNtF13vyYBAJjktam6
2026-08-16 14:49:27 -04:00
Isaac ConnorandClaude Sonnet 5 06801955a8 fix: add ServerId index to Server_Stats to speed up per-server stats lookup
Server.php's ReadStats() query (SELECT ... WHERE ServerId=? ORDER BY
TimeStamp DESC LIMIT 1) had no supporting index, forcing a full table
scan on every page load that shows server stats. Add a composite
(ServerId, TimeStamp) index via zm_update-1.39.18.sql (idempotent,
checked against INFORMATION_SCHEMA.STATISTICS) and zm_create.sql.in
for fresh installs. Keep the existing TimeStamp-only index, since
zmstats.pl's prune DELETE filters by TimeStamp alone.

Also query ReadStats() with the server's actual Id() instead of
coercing Id() <= 1 to 0.

Bump version.txt and the redhat spec Version to 1.39.18.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7jeoG8ptXRpXxif2JqkD7
2026-07-23 14:01:24 -04:00
Isaac ConnorandClaude Opus 4.8 d8d9452179 feat: add AI object-detection data model
Add the schema backing per-monitor object detection and the AI dataset/
model/class management UI:

- Monitors gains AnalysisImageOpacity and ObjectDetection,
  ObjectDetectionModel, ObjectDetectionObjectThreshold,
  ObjectDetectionNMSThreshold columns.
- New tables AI_Datasets, AI_Models, AI_Object_Classes,
  AI_Detection_Settings and AI_Detections.
- Seed the COCO 2017 dataset (80 classes) and default per-class
  detection settings via db/coco_dataset.sql.

Existing installs get zm_update-1.39.17.sql, which is idempotent and
adds the columns with their final VARCHAR(16) ObjectDetection shape
directly (no enum-churn intermediates). Fresh installs create the same
objects from zm_create.sql.in sourcing AI_Models.sql and
coco_dataset.sql. Both paths were verified to produce identical schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:24:50 -04:00
Isaac ConnorandClaude Opus 4.8 48fadaebbf feat: customizable menu entries with links, icons and delete on options menu tab
Port the menu customization work from the ai_server branch onto master:
- Add/edit/delete custom navbar/sidebar menu entries via Options > Menu
- Per-entry Link column (new Menu_Items.Link) with ?view= fallback derived
  the same way as built-in items; custom entries render via buildMenuItem
- Live icon preview (material/font awesome) with fixed-width preview cell
- Per-row delete icon; add/reset buttons with tooltips
- Surface DB errors when saving options config: capture the swallowed PDO
  error via dbLastError() and show it in the page error banner instead of
  redirecting past it

Menu_Items.Link is added to zm_create.sql.in and as an idempotent ALTER in
zm_update-1.39.16.sql.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:13:49 -04:00
Isaac ConnorandClaude Opus 4.8 012cb2c6d5 feat: add LTS CMIP3CD42WI-28AISP white-light control row refs #4895
Front Culdesac (a CMIP3CD42WI-28AISP ColorVu camera) exposes the same
colorVuWhiteLight supplement-light interface as the CMIP1342WE-28MDA but was
assigned a non-HikVision control, so it showed no Light button. Add a
model-specific Controls row (HikVision protocol, CanLight only; fixed camera
with no PTZ/focus/iris) in zm_create.sql.in and migration 1.39.16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:31:37 -05:00
Isaac ConnorandClaude Opus 4.8 e5bdb5f7a1 feat: add white-light (CanLight) control to HikVision/LTS module refs #4895
Add lightOn/lightOff/lightStatus to the HikVision control module, driving the
camera's ISAPI supplement light (ISAPI/Image/channels/<n>/supplementLight).
"On" selects the white light where the model has one (colorVuWhiteLight), else
IR; "off" restores the camera's smart/auto default (eventIntelligence) so night
IR keeps working. The methods GET the current document and rewrite only
<supplementLightMode>, preserving the std-cgi namespace and the sibling
brightness fields the firmware rejects PUTs without. Mode selection is driven by
the model's advertised supplementLight/capabilities. lightStatus returns
{ WhiteLight => 'On'|'Off'|undef } in the shape the existing web toggle consumes,
so no web changes are needed (the CanLight UI is already generic).

Add a model-specific Controls row for the LTS CMIP1342WE-28MDA (a fixed ColorVu
camera: white light, reboot, no PTZ/focus/iris), in zm_create.sql.in and
migration zm_update-1.39.16.sql. Bump version to 1.39.16.

The pure mode-selection/XML helpers are covered by t/hikvision_light.t. Verified
live against a CMIP1342WE-28MDA: the GET-modify-PUT round-trip turns the white
light on and restores the prior mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:59:10 -05:00
Isaac ConnorandClaude Opus 4.8 821042ebcb fix: align positional Controls seed rows with CanLight/CanIndicatorLight columns
The CanLight and CanIndicatorLight columns were added to the Controls table
(between MaxWhiteSpeed and HasPresets) without updating the legacy positional
INSERT ... VALUES (NULL, ...) seed rows. All 43 of those rows were left two
values short, so a fresh `mysql < zm_create.sql` load failed with ERROR 1136
(Column count doesn't match value count).

Splice CanLight=0,CanIndicatorLight=0 into each positional row at the matching
column position. None of these legacy models support either capability, so 0,0
is correct; existing installs are unaffected (the columns were added there by
the 1.39.13/1.39.14 ALTERs with the same default). Named-column rows already
specified the values and are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:57:49 -05:00
Isaac ConnorandClaude Opus 4.8 85abc4b9a6 feat: add HiSilicon Hi3510 CGI PTZ control module
Adds a control protocol module for cameras built on the HiSilicon
Hi3510 SoC which expose cgi-bin/hi3510/ptzctrl.cgi rather than the
older Foscam decoder_control.cgi interface. Contributed by Turgut
Kalfaoglu on the forums, tested on a Tenvis TH661.

Supports continuous pan/tilt with auto-stop, emulated diagonals,
presets 1-8, and horizontal/vertical patrol via presets 9/10.
Credential and host parsing uses the Control.pm guess_credentials
helper; the camera-tested wire format (usr/pwd query parameters)
is preserved.

Adds the Controls table row to zm_create.sql.in and an idempotent
zm_update-1.39.15.sql migration, and bumps version to 1.39.15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:34:56 -04:00
Isaac ConnorandClaude Opus 4.8 f02bc29c0d feat: add Indicator LED control capability with session keepalive refs #4875
Add a CanIndicatorLight capability and status-aware Indicator toggle button. The indicator LED on the ASH21-B, ADC2W and ASH42-B is controlled via the LightGlobal config (configManager get/setConfig); add indicatorLightOn/Off/Status to Dahua_RPC and a model-specific 'Amcrest ASH42-B RPC' Controls row, with the capability also enabled on the ASH21-B/ADC2W/generic rows. Migration zm_update-1.39.13.sql adds the column.

Add Dahua_RPC keepAlive (global.keepAlive) wired into a 30s zmcontrol idle tick, plus session-expiry re-login retry in set_config and the status queries, so the long-running control daemon does not silently fail after the ~60s session timeout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:00:00 -04:00
Isaac ConnorandClaude Opus 4.8 fd83672a11 feat: add status-aware Light control capability and control-command response path refs #4869
Add a CanLight control capability rendering a single status-aware Light toggle button. The ADC2W white light is driven via CoaxialControlIO.control (Type 1, numeric IO); the button queries live state and reflects it (amber when on).

To get device state to the browser, add an opt-in two-way response path to the control protocol: zmcontrol writes a JSON result back only when a request sets wants_response (fire-and-forget commands unchanged, SIGPIPE-safe); Monitor::sendControlCommandWithResponse and ajax/control.php return it.

Also adds get_config/set_config/probe to Dahua_RPC for characterising cameras, the CanLight column (migration zm_update-1.39.12.sql), edit-UI checkbox, and a config unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:00:00 -04:00
Isaac ConnorandClaude Opus 4.8 b180b1b736 feat: add Dahua/Amcrest RPC PTZ control module with per-model Controls refs #120
Add ZoneMinder::Control::Dahua_RPC, a PTZ control module driving Amcrest Smart Home (ASH21/ASH42/ADC2W) and Dahua cameras over the JSON-RPC /RPC2 interface, since their cgi-bin API is disabled and ONVIF exposes no PTZ service. Two-stage MD5-challenge login with session reuse and self-healing re-login; continuous pan/tilt + diagonals, stop, presets, zoom, focus, reboot.

Adds a generic 'Dahua/Amcrest RPC' Controls row plus model-specific rows for the ASH21-B (pan/tilt only) and ADC2W (reboot only), the ASH21-B/ADC2W models, migration zm_update-1.39.11.sql, and a login-hash unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:00:00 -04:00
Jash 1ba3169de7 Merge branch 'master' of https://github.com/AJ0070/zoneminder into fix/4750 2026-05-29 09:45:51 +05:30
Jash 202753e99c fix: widen DefaultScale columns to preserve fit_to_width 2026-05-29 09:41:46 +05:30
Isaac Connor a2e4c84750 Merge branch 'master' into patch-89533 2026-05-14 17:30:19 -04:00
Isaac Connor 327b8d4a89 Merge branch 'master' of github.com:ZoneMinder/zoneminder 2026-05-14 07:40:14 -04:00
Isaac ConnorandClaude Opus 4.7 8fd17a4b91 perf: index Sessions.access and rework session gc to two-phase delete
The session garbage collector ran DELETE FROM Sessions WHERE access < ?
against an unindexed column, forcing a full table scan and taking gap
locks across the access range. With REPLACE INTO Sessions happening on
every authenticated request, this is a deadlock hotspot.

- Add Sessions_access_idx on Sessions(access) in both fresh-install
  schema (zm_create.sql.in) and a migration (zm_update-1.39.10.sql).
- Rewrite ZMSessionHandler::gc to a two-phase delete: SELECT up to 100
  expired ids via the new index (consistent read, no locks), then
  DELETE WHERE id IN (...) by primary key. InnoDB takes record locks
  only on the matched rows, not gap locks on the access range.
- Bump version to 1.39.10 so zmupdate.pl picks up the new migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:38:18 -04:00
Isaac Connor 027ed7e1c2 feat: recalibrate ZonePresets for modern HD resolutions
The stock preset MinAlarm/MinFilter/MinBlob values were authored for
~320x240 cameras, where 5% of zone area was a 62x62 motion blob.
At 1080p, the same 5% requires a 322x322 blob to trigger — so even
the "Fast, high sensitivity" preset rarely fires on a person walking.

New scale (% of zone area): low=3, medium~0.5, high=0.1.
MaxPixelThreshold (per-pixel grayscale delta, 0-255) is unchanged.

Updates zm_create.sql.in for fresh installs and adds a migration
that rewrites Ids 1-7 on existing installs. Custom presets (Id > 7)
are left alone. Bumps version to 1.39.10.
2026-05-13 15:18:34 -04:00
IgorA100 a5114da21b The column order in the component index has been changed, and Logs_Component_idx has been removed, as it's now redundant. 2026-05-13 12:46:42 +03:00
IgorA100 148313c389 Added composite secondary index (zm_create.sql.in) 2026-05-12 20:27:39 +03:00
IgorA100 36955257d4 Add Component, TimeKey, and Level to the primary index to increase query processing speed. (zm_create.sql.in) 2026-05-10 20:08:34 +03:00
Isaac ConnorandClaude Opus 4.7 c4073c964c feat: encoder parameter templates with editor + REST API closes #4778 closes #4802
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>
2026-05-06 17:24:17 -04:00
Isaac ConnorandClaude Opus 4.7 2630d55ffb feat: add Report::canEdit() and CreatedBy column
Add a CreatedBy column to the Reports table and a canEdit() method on
the Report class so $report->canEdit() (already called from
web/ajax/reports.php) resolves to a real check. canEdit() permits the
report owner (CreatedBy == user) or any user/role with System=Edit.
Wire actions/report.php to stamp CreatedBy on first save and refuse
save/delete on existing reports the current user cannot edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:24:15 -04:00
IgorA100 1459d67f8a Added WhatDisplay to the 'Monitors' table (zm_create.sql.in)
In addition to https://github.com/ZoneMinder/zoneminder/pull/4743
2026-04-26 21:17:54 +03:00
Isaac ConnorandClaude Opus 4.6 d18df591c2 fix: restore getCycleHTML for Watch/Cycle navbar item refs #4745
Accidentally removed in 4bd56c22d when refactoring menu rendering. The
1.39.3 migration seeds a 'Watch' Menu_Items row, but there was no
function registered for that key, so the navbar entry never rendered.

Restore getCycleHTML with the current menu-function signature
(custom label, skipTranslate) and map the 'Watch' menu key to it.
Add the 'Watch' row to zm_create.sql.in for fresh installs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 08:17:32 -04:00
Isaac Connor 0206fcf963 Merge branch 'master' into feat/notification-profile 2026-03-10 08:41:00 -04:00
Pliable PixelsandClaude Opus 4.6 1112d9fff4 feat: add Profile column to Notifications table
Allows push token registrations to include a profile name
(e.g. 'Home Server') that is passed through to FCM push
notifications for display in the app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 05:26:24 -04:00
Isaac ConnorandClaude Opus 4.6 801663149e fix: use DECIMAL(10,2) for zone threshold columns to hold legacy pixel counts
DECIMAL(7,2) maxes at 99999.99 which is too small for legacy zones still
storing pixel counts (e.g. 2,073,600 for 1080p). DECIMAL(10,2) holds up
to 99,999,999.99 which accommodates any resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:25:12 -04:00
Isaac ConnorandClaude Opus 4.6 29d371313a fix: store zone thresholds as percentages and convert to pixels at runtime
Zone threshold fields (MinAlarmPixels, MaxAlarmPixels, MinFilterPixels,
MaxFilterPixels, MinBlobPixels, MaxBlobPixels) were being corrupted on
every save because the PHP conversion used monitor pixel area instead of
zone pixel area. This caused values to inflate progressively, breaking
motion detection.

The fix changes the storage model: thresholds are now stored as
percentages of zone area (DECIMAL(7,2) columns) matching the percentage
coordinate system from zm_update-1.39.2. The C++ Zone::Load() converts
percentages to pixel counts at runtime using polygon.Area(). Legacy
pixel-coordinate zones pass through unchanged.

JS changes:
- submitForm() converts to percentages when in Pixels display mode
- Init skips applyZoneUnits() since DB values are already percentages
- limitRange() only updates field.value when constrained value differs,
  preventing oninput from stripping decimal points mid-keystroke

fixes #4690

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 17:16:56 -04:00
Isaac ConnorandClaude Opus 4.6 47cdb699ba feat: add 'none' icon type, show icons in navbar, fix console column dropdown
- Add 'none' as icon type option to hide icons on individual menu items
- Display custom icons in top navbar to match left sidebar appearance
- Fix console table Columns dropdown showing raw HTML by moving icon/link
  rendering to JS post-init (bootstrap-table captures th innerHTML for
  dropdown labels, so icons must be injected after initialization)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:59:40 -04:00
Isaac ConnorandClaude Opus 4.6 7364ba036a feat: add custom icon support to menu items (material, font-awesome, image upload)
Add Icon and IconType columns to Menu_Items table allowing admins to
override the default material icon for each menu entry. Supports three
icon types: Material Icons, Font Awesome 4.7 classes, and uploaded
image files (stored in graphics/menu/).

Add renderMenuIcon() helper and $menuIconOverride global to pass
custom icons through to buildMenuItem() and getOptionsHTML() without
changing every get*HTML() function signature.

Options > Menu tab now shows icon preview, type selector, and
text/file input per row. Upload handler validates file type and
cleans up old images on change or reset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:01:05 -04:00
Isaac ConnorandClaude Opus 4.6 4bd56c22d9 feat: add customizable navbar/sidebar menu via Options > Menu tab
Add Menu_Items table to store per-item enabled state, custom labels,
and sort order. Admins can enable/disable menu items, set custom
labels, and reorder via drag-and-drop in Options > Menu tab.

Refactor hardcoded get*HTML() calls in buildSidebarMenu(),
getNormalNavBarHTML(), and getCollapsedNavBarHTML() to data-driven
renderMenuItems() that reads from DB with fallback for empty table.

Remove deprecated Cycle view from menu (functionality merged into
Watch view). Add reset button to restore default menu configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 14:37:14 -04:00
Pliable PixelsandClaude Opus 4.6 fea1c850ac fix: address Copilot review feedback on Notifications API refs #4684
- Revert accidental Users.RoleId FK change from CASCADE back to SET NULL
- Remove System != 'None' gate in beforeFilter; any authenticated user
  can manage their own notifications, per-row ownership checks suffice
- Add allowMethod('post', 'put') guard to edit() for consistent REST behavior
- Change PushState validation from allowEmpty to required=false

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:39:54 -05:00
Pliable PixelsandClaude Opus 4.6 1c3c150e7e fix: change Notifications FK to ON DELETE CASCADE refs #4684
Delete notification tokens when the owning user is deleted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:12:11 -05:00
Pliable PixelsandClaude Opus 4.6 9c455cc29d fix: make UserId nullable for no-auth mode refs #4684
- UserId is now DEFAULT NULL instead of NOT NULL
- FK changed to ON DELETE SET NULL (keep token if user deleted)
- Removed auth guard from add() — no-auth mode stores NULL UserId
- No-auth mode already treated as admin by _isAdmin(), so scoping
  works correctly (sees all tokens)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:09:32 -05:00
Pliable PixelsandClaude Opus 4.6 c6effc12ab fix: add FK constraint, auth guard, and belongsTo for Notifications refs #4684
- Add FOREIGN KEY on UserId -> Users.Id with ON DELETE CASCADE
  (both in fresh schema and migration)
- Reject push token registration when auth is disabled
  (UserId would be null, violating NOT NULL constraint)
- Add $belongsTo association to User in Notification model

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:04:03 -05:00
Pliable PixelsandClaude Opus 4.6 2fce145a90 feat: add Notifications table for push token registration refs #4684
Add Notifications table to fresh-install schema (zm_create.sql.in) and
create idempotent migration (zm_update-1.39.2.sql) for existing installs.
The table stores FCM push notification tokens so zm_detect can send
push notifications directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:45:46 -05:00