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

261 lines
7.7 KiB
PHP

<?php
namespace ZM;
require_once('database.php');
require_once('Object.php');
class Control extends ZM_Object {
protected static $table = 'Controls';
protected $defaults = array(
'Id' => null,
'CanMove' => 0,
'CanMoveDiag' => 0,
'CanMoveMap' => 0,
'CanMoveAbs' => 0,
'CanMoveRel' => 0,
'CanMoveCon' => 0,
'CanPan' => 0,
'CanReset' => 0,
'CanReboot' => 0,
'CanSleep' => 0,
'CanWake' => 0,
'MinPanRange' => NULL,
'MaxPanRange' => NULL,
'MinPanStep' => NULL,
'MaxPanStep' => NULL,
'HasPanSpeed' => 0,
'MinPanSpeed' => NULL,
'MaxPanSpeed' => NULL,
'HasTurboPan' => 0,
'TurboPanSpeed' => NULL,
'CanTilt' => 0,
'MinTiltRange' => NULL,
'MaxTiltRange' => NULL,
'MinTiltStep' => NULL,
'MaxTiltStep' => NULL,
'HasTiltSpeed' => 0,
'MinTiltSpeed' => NULL,
'MaxTiltSpeed' => NULL,
'HasTurboTilt' => 0,
'TurboTiltSpeed' => NULL,
'CanZoom' => 0,
'CanZoomAbs' => 0,
'CanZoomRel' => 0,
'CanZoomCon' => 0,
'MinZoomRange' => NULL,
'MaxZoomRange' => NULL,
'MinZoomStep' => NULL,
'MaxZoomStep' => NULL,
'HasZoomSpeed' => 0,
'MinZoomSpeed' => NULL,
'MaxZoomSpeed' => NULL,
'CanFocus' => 0,
'CanAutoFocus' => 0,
'CanFocusAbs' => 0,
'CanFocusRel' => 0,
'CanFocusCon' => 0,
'MinFocusRange' => NULL,
'MaxFocusRange' => NULL,
'MinFocusStep' => NULL,
'MaxFocusStep' => NULL,
'HasFocusSpeed' => 0,
'MinFocusSpeed' => NULL,
'MaxFocusSpeed' => NULL,
'CanIris' => 0,
'CanAutoIris' => 0,
'CanIrisAbs' => 0,
'CanIrisRel' => 0,
'CanIrisCon' => 0,
'MinIrisRange' => NULL,
'MaxIrisRange' => NULL,
'MinIrisStep' => NULL,
'MaxIrisStep' => NULL,
'HasIrisSpeed' => 0,
'MinIrisSpeed' => NULL,
'MaxIrisSpeed' => NULL,
'CanGain' => 0,
'CanAutoGain' => 0,
'CanGainAbs' => 0,
'CanGainRel' => 0,
'CanGainCon' => 0,
'MinGainRange' => NULL,
'MaxGainRange' => NULL,
'MinGainStep' => NULL,
'MaxGainStep' => NULL,
'HasGainSpeed' => 0,
'MinGainSpeed' => NULL,
'MaxGainSpeed' => NULL,
'CanWhite' => 0,
'CanAutoWhite' => 0,
'CanWhiteAbs' => 0,
'CanWhiteRel' => 0,
'CanWhiteCon' => 0,
'MinWhiteRange' => NULL,
'MaxWhiteRange' => NULL,
'MinWhiteStep' => NULL,
'MaxWhiteStep' => NULL,
'HasWhiteSpeed' => 0,
'MinWhiteSpeed' => NULL,
'MaxWhiteSpeed' => NULL,
'CanLight' => 0,
'CanIndicatorLight' => 0,
'CanAudioPlay' => 0,
'MinAudioFile' => NULL,
'MaxAudioFile' => NULL,
'CanAudioVolume' => 0,
'HasPresets' => 0,
'NumPresets' => 0,
'HasHomePreset' => 0,
'CanSetPresets' => 0,
'Name' => 'New',
'Type' => 'Local',
'Protocol' => NULL
);
public static function find( $parameters = array(), $options = array() ) {
return ZM_Object::_find(self::class, $parameters, $options);
}
public static function find_one( $parameters = array(), $options = array() ) {
return ZM_Object::_find_one(self::class, $parameters, $options);
}
public function commands() {
$cmds = array();
$cmds['Wake'] = 'wake';
$cmds['Sleep'] = 'sleep';
$cmds['Reset'] = 'reset';
$cmds['Reboot'] = 'reboot';
$cmds['LightOn'] = 'lightOn';
$cmds['LightOff'] = 'lightOff';
$cmds['IndicatorLightOn'] = 'indicatorLightOn';
$cmds['IndicatorLightOff'] = 'indicatorLightOff';
$cmds['AudioPlay'] = 'audioPlay';
$cmds['AudioStop'] = 'audioStop';
$cmds['AudioVolumeUp'] = 'audioVolumeUp';
$cmds['AudioVolumeDown'] = 'audioVolumeDown';
$cmds['PresetSet'] = 'presetSet';
$cmds['PresetGoto'] = 'presetGoto';
$cmds['PresetHome'] = 'presetHome';
if ( $this->CanZoom() ) {
if ( $this->CanZoomCon() ) {
$cmds['ZoomRoot'] = 'zoomCon';
} else if ( $this->CanZoomRel() ) {
$cmds['ZoomRoot'] = 'zoomRel';
} else if ( $this->CanZoomAbs() ) {
$cmds['ZoomRoot'] = 'zoomAbs';
} else {
$cmds['ZoomRoot'] = '';
Error('No zoom type selected. Please select Continuous, Relative, Absolute');
}
$cmds['ZoomTele'] = $cmds['ZoomRoot'].'Tele';
$cmds['ZoomWide'] = $cmds['ZoomRoot'].'Wide';
$cmds['ZoomStop'] = 'zoomStop';
$cmds['ZoomAuto'] = 'zoomAuto';
$cmds['ZoomMan'] = 'zoomMan';
}
if ( $this->CanFocus() ) {
if ( $this->CanFocusCon() ) {
$cmds['FocusRoot'] = 'focusCon';
} else if ( $this->CanFocusRel() ) {
$cmds['FocusRoot'] = 'focusRel';
} else if ( $this->CanFocusAbs() ) {
$cmds['FocusRoot'] = 'focusAbs';
} else {
$cmds['FocusRoot'] = '';
Error('No focus type selected. Please select Continuous, Relative, Absolute');
}
$cmds['FocusFar'] = $cmds['FocusRoot'].'Far';
$cmds['FocusNear'] = $cmds['FocusRoot'].'Near';
$cmds['FocusStop'] = 'focusStop';
$cmds['FocusAuto'] = 'focusAuto';
$cmds['FocusMan'] = 'focusMan';
}
if ( $this->CanIris() ) {
if ( $this->CanIrisCon() ) {
$cmds['IrisRoot'] = 'irisCon';
} else if ( $this->CanIrisRel() ) {
$cmds['IrisRoot'] = 'irisRel';
} else if ( $this->CanIrisAbs() ) {
$cmds['IrisRoot'] = 'irisAbs';
} else {
$cmds['IrisRoot'] = '';
Error('No iris type selected. Please select Continuous, Relative, Absolute');
}
$cmds['IrisOpen'] = $cmds['IrisRoot'].'Open';
$cmds['IrisClose'] = $cmds['IrisRoot'].'Close';
$cmds['IrisStop'] = 'irisStop';
$cmds['IrisAuto'] = 'irisAuto';
$cmds['IrisMan'] = 'irisMan';
}
if ( $this->CanWhite() ) {
if ( $this->CanWhiteCon() ) {
$cmds['WhiteRoot'] = 'whiteCon';
} else if ( $this->CanWhiteRel() ) {
$cmds['WhiteRoot'] = 'whiteRel';
} else if ( $this->CanWhiteAbs() ) {
$cmds['WhiteRoot'] = 'whiteAbs';
} else {
Error('No White type selected. Please select Continuous, Relative, Absolute');
$cmds['WhiteRoot'] = '';
}
$cmds['WhiteIn'] = $cmds['WhiteRoot'].'In';
$cmds['WhiteOut'] = $cmds['WhiteRoot'].'Out';
$cmds['WhiteAuto'] = 'whiteAuto';
$cmds['WhiteMan'] = 'whiteMan';
}
if ( $this->CanGain() ) {
if ( $this->CanGainCon() ) {
$cmds['GainRoot'] = 'gainCon';
} else if ( $this->CanGainRel() ) {
$cmds['GainRoot'] = 'gainRel';
} else if ( $this->CanGainAbs() ) {
$cmds['GainRoot'] = 'gainAbs';
} else {
Error('No Gain type selected');
$cmds['GainRoot'] = '';
}
$cmds['GainUp'] = $cmds['GainRoot'].'Up';
$cmds['GainDown'] = $cmds['GainRoot'].'Down';
$cmds['GainAuto'] = 'gainAuto';
$cmds['GainMan'] = 'gainMan';
}
if ( $this->CanMove() ) {
if ( $this->CanMoveCon() ) {
$cmds['MoveRoot'] = 'moveCon';
$cmds['Center'] = 'moveStop';
} elseif ( $this->CanMoveRel() ) {
$cmds['MoveRoot'] = 'moveRel';
$cmds['Center'] = $cmds['PresetHome'];
} elseif ( $this->CanMoveAbs() ) {
$cmds['MoveRoot'] = 'moveAbs';
$cmds['Center'] = $cmds['PresetHome'];
} else {
$cmds['MoveRoot'] = '';
Error('No move type selected. Please select Continuous, Relative, Absolute');
}
$cmds['MoveUp'] = $cmds['MoveRoot'].'Up';
$cmds['MoveDown'] = $cmds['MoveRoot'].'Down';
$cmds['MoveLeft'] = $cmds['MoveRoot'].'Left';
$cmds['MoveRight'] = $cmds['MoveRoot'].'Right';
$cmds['MoveUpLeft'] = $cmds['MoveRoot'].'UpLeft';
$cmds['MoveUpRight'] = $cmds['MoveRoot'].'UpRight';
$cmds['MoveDownLeft'] = $cmds['MoveRoot'].'DownLeft';
$cmds['MoveDownRight'] = $cmds['MoveRoot'].'DownRight';
}
return $cmds;
} // end public function commands
} // end class Control
?>