Files
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

80 lines
4.2 KiB
Perl

use strict;
use warnings;
use Test::More tests => 32;
require_ok('ZoneMinder::Control::IPSpeaker');
my $P = 'ZoneMinder::Control::IPSpeaker';
# --- file id validation -------------------------------------------------------
#
# The firmware answers result -2 "music file value error" for an id outside its
# two windows and -3 "music file empty" for an id inside a window with nothing
# uploaded. Only the first is worth refusing locally: an empty user slot is a
# legitimate id that the operator may fill later.
ok($P->can('valid_fileid')->(10), 'first system sound is a valid id');
ok($P->can('valid_fileid')->(14), 'last system sound is a valid id');
ok(!$P->can('valid_fileid')->(9), 'id below the system window is refused');
ok(!$P->can('valid_fileid')->(15), 'id in the gap between windows is refused');
ok($P->can('valid_fileid')->(20), 'first user slot is a valid id');
ok($P->can('valid_fileid')->(30), 'last user slot is a valid id');
ok(!$P->can('valid_fileid')->(31), 'id above the user window is refused');
ok(!$P->can('valid_fileid')->('abc'), 'non-numeric id is refused');
ok(!$P->can('valid_fileid')->(''), 'empty id is refused');
ok(!$P->can('valid_fileid')->(undef), 'undefined id is refused');
# --- volume arithmetic --------------------------------------------------------
is($P->can('clamp_volume')->(-5), 0, 'volume below range clamps to the floor');
is($P->can('clamp_volume')->(0), 0, 'zero is a legal volume');
is($P->can('clamp_volume')->(60), 60, 'in-range volume is unchanged');
is($P->can('clamp_volume')->(100), 100, 'top of range is a legal volume');
is($P->can('clamp_volume')->(150), 100, 'volume above range clamps to the ceiling');
is($P->can('stepped_volume')->(60, 5), 65, 'a step up adds to the current volume');
is($P->can('stepped_volume')->(60, -5), 55, 'a step down subtracts from it');
is($P->can('stepped_volume')->(98, 5), 100, 'a step up saturates at the ceiling');
is($P->can('stepped_volume')->(2, -5), 0, 'a step down saturates at the floor');
is($P->can('stepped_volume')->('60', 5), 65, 'a volume read back as a string still works');
is($P->can('stepped_volume')->(undef, 5), undef, 'an unreadable current volume yields undef');
# --- request building ---------------------------------------------------------
is($P->can('spk_path')->('start', 10), '/api/spkplay?action=start&fileid=10',
'play builds the documented query');
is($P->can('spk_path')->('stop', 30), '/api/spkplay?action=stop&fileid=30',
'stop builds the documented query');
# --- audio.set is a whole-section write ---------------------------------------
#
# The firmware replaces the entire audio section on every .set, so a partial
# write silently reverts every field left out. audio_set_form must echo the
# current values back with only the requested override applied.
my %current = (
micgain => '0', micenable => '1', micvolume => '80', outvolume => '60',
audiojitterbuffer => '360', codec => 'opus,pcmu,pcma,gsm,g722',
playring => 'ring1', automute => '1', aecenable => '1', aectime => '20',
agcenable => '1', agcmode => '0', agctarget => '3', agcgain => '30',
nrenable => '1', nrlevel => '1', michpfenable => '0', outhpfenable => '0',
outnrenable => '0', outnrlevel => '3',
outmute => '0', # returned by audio.get but not accepted by audio.set
);
my $form = $P->can('audio_set_form')->(\%current, outvolume => 15);
is($form->{outvolume}, 15, 'the requested override is applied');
is($form->{micvolume}, '80', 'an untouched field is echoed back unchanged');
is($form->{codec}, 'opus,pcmu,pcma,gsm,g722', 'the codec list survives the round trip');
is($form->{agcgain}, '30', 'a field the ui hides is still preserved');
ok(!exists $form->{outmute},
'outmute is dropped because audio.set does not accept it');
is(scalar keys %$form, 20, 'every field audio.set expects is present, and no others');
# A device that answered .get with a missing field must not gain an empty one.
my %sparse = (outvolume => '60', micvolume => '80');
my $sparse_form = $P->can('audio_set_form')->(\%sparse, outvolume => 0);
is_deeply([sort keys %$sparse_form], [qw(micvolume outvolume)],
'fields absent from the device response are not invented');
is($sparse_form->{outvolume}, 0, 'a zero override is applied, not treated as absent');