use std::net::Ipv4Addr;
use std::sync::{Arc, Mutex};
use crate::event::EventHandler;
use crate::testing::{bay_config_rec, datagram, hello_payload, stream_rec, uid_n};
use crate::types::{
ActionTransmitRequest, AmpZoneSettings, AudioChangeSource, BayNameChange, EdidProfileChange,
V2ipAudioFormat,
};
use crate::wire::{
build_amp_zone_settings, build_v2ip_manual_source_switch, op, protocol_for, Addressee,
BayFeatures, BayStatus, BayUid, Conn, DeviceFeature, DeviceUid, EdidProfile, MultiviewerSource,
MultiviewerViewMode, Opcode, RcAction, SendError, StreamAddr, V2ipStreams, HEADER_LEN,
PROTOCOL_VERSION, V2IP_PORT_ANC, V2IP_PORT_AUDIO, V2IP_PORT_VIDEO,
};
use super::control::ControlError;
use super::tests::{client_with, Tap};
use super::*;
const FROM: Ipv4Addr = Ipv4Addr::new(10, 8, 8, 9);
const CLIENT: u8 = 0xFE;
const SCRATCH_PORT: u16 = 18818;
fn refused(result: &Result<(), ControlError>) -> bool {
matches!(
result,
Err(ControlError::Send(SendError::ProtocolTooOld { .. }))
)
}
struct Fixture {
remote: Remote,
tap: Arc<Tap>,
}
impl Fixture {
fn new() -> Self {
Self::with_handler(Arc::new(()))
}
fn with_handler(handler: Arc<dyn EventHandler>) -> Self {
let (remote, tap) = client_with(CLIENT, handler);
Self { remote, tap }
}
fn feed(&self, sender: DeviceUid, opcode: Opcode, payload: &[u8]) {
self.feed_proto(sender, opcode, PROTOCOL_VERSION, payload);
}
fn feed_proto(&self, sender: DeviceUid, opcode: Opcode, protocol: u16, payload: &[u8]) {
self.remote
.shared
.process_datagram(&datagram(sender, opcode, protocol, payload), FROM);
}
fn amplifier(&self, uid: DeviceUid, protocol: u16, serial: &str) {
self.feed(
uid,
op::SYS_HELLO,
&hello_payload(
protocol,
"ProAmp8",
serial,
"4.8.0",
DeviceFeature::AUDIO_AMPLIFIER,
),
);
self.feed(
uid,
op::SYS_BAY_CONFIG,
&bay_config_rec(
1,
1,
0,
"Zone 1",
"Hall",
BayStatus::NONE,
BayFeatures::AUDIO_AMP_OUT,
),
);
}
fn everything(&self, uid: DeviceUid, protocol: u16, serial: &str) {
self.feed(
uid,
op::SYS_HELLO,
&hello_payload(
protocol,
"OneIP",
serial,
"4.8.0",
DeviceFeature::V2IP_SINK
| DeviceFeature::V2IP_SOURCE
| DeviceFeature::MULTIVIEWER
| DeviceFeature::AUDIO_AMPLIFIER
| DeviceFeature::VIDEO_ROUTING
| DeviceFeature::VOLUME_CONTROL
| DeviceFeature::AUDIO_ROUTING,
),
);
let mut bays = bay_config_rec(
1,
0,
0,
"Input 1",
"Apple TV",
BayStatus::NONE,
BayFeatures::HDMI_IN | BayFeatures::V2IP_SOURCE_LOCAL,
);
bays.extend_from_slice(&bay_config_rec(
2,
1,
0,
"Output 1",
"TV",
BayStatus::NONE,
BayFeatures::HDMI_OUT | BayFeatures::AUDIO_AMP_OUT | BayFeatures::V2IP_SINK_LOCAL,
));
self.feed(uid, op::SYS_BAY_CONFIG, &bays);
let n = uid.as_bytes()[0];
self.feed(
uid,
op::SYS_BAY_V2IP_SOURCES,
&stream_rec(
uid,
&format!("239.7.{n}.1"),
&format!("239.7.{n}.2"),
&format!("239.7.{n}.3"),
V2IP_PORT_VIDEO,
),
);
}
fn round_trip(&self, what: &str, sender: DeviceUid, call: Result<(), ControlError>) {
assert!(
matches!(call, Err(ControlError::Send(SendError::NotConnected))),
"{what} failed for a reason other than the missing socket: {call:?}"
);
let frames = self.tap.frames();
assert_eq!(frames.len(), 1, "{what} captured {} frames", frames.len());
let mut frame = frames.into_iter().next().expect("one frame");
frame[4..20].copy_from_slice(sender.as_bytes());
self.remote.shared.process_datagram(&frame, FROM);
self.tap.clear();
}
fn connect(&self) {
let conn = Conn::open(
Ipv4Addr::LOCALHOST,
SCRATCH_PORT,
Some(Ipv4Addr::LOCALHOST),
None,
)
.expect("a loopback socket could not be opened");
lock(&self.remote.shared.tx).set_conn(Some(conn));
}
}
#[test]
fn a_send_is_refused_below_the_opcode_floor() {
let f = Fixture::new();
let old = uid_n(150);
f.amplifier(old, 0x11, "AMP4111");
let zone = BayUid::new(old, 1);
assert!(refused(
&f.remote
.set_amp_zone_settings(zone, AmpZoneSettings::default())
));
assert!(refused(&f.remote.subscribe_v2ip_stats(old, true)));
assert!(!refused(&f.remote.reboot(old)));
let unknown = uid_n(152);
f.amplifier(unknown, 0, "UNK0001");
assert!(!refused(&f.remote.set_amp_zone_settings(
BayUid::new(unknown, 1),
AmpZoneSettings::default()
)));
let current = uid_n(151);
f.amplifier(current, 0x28, "AMP0480");
assert!(!refused(&f.remote.set_amp_zone_settings(
BayUid::new(current, 1),
AmpZoneSettings::default()
)));
}
type Guarded = (
&'static str,
fn(&Remote, DeviceUid) -> Result<(), ControlError>,
);
const GUARDED_SENDS: &[Guarded] = &[
("select_video_source", |r, d| {
r.select_video_source(BayUid::new(d, 2), 1)
}),
("select_audio_source", |r, d| {
r.select_audio_source(BayUid::new(d, 2), 1)
}),
("select_video_source_by_name", |r, d| {
r.select_video_source_by_name(BayUid::new(d, 2), "Apple TV")
}),
("select_audio_source_by_name", |r, d| {
r.select_audio_source_by_name(BayUid::new(d, 2), "Apple TV", None)
}),
("select_audio_source_by_name/format", |r, d| {
r.select_audio_source_by_name(
BayUid::new(d, 2),
"Apple TV",
Some(V2ipAudioFormat {
sample_rate: 48000,
channels: 2,
}),
)
}),
("select_audio_source_addr", |r, d| {
r.select_audio_source_addr(BayUid::new(d, 2), Ipv4Addr::new(239, 1, 1, 1), None, None)
}),
("set_bay_name", |r, d| {
r.set_bay_name(BayUid::new(d, 2), "Kitchen")
}),
("set_bay_hidden", |r, d| {
r.set_bay_hidden(BayUid::new(d, 2), true)
}),
("select_edid_profile", |r, d| {
r.select_edid_profile(BayUid::new(d, 1), EdidProfile::UHD_4K)
}),
("send_action", |r, d| {
r.send_action(BayUid::new(d, 2), RcAction::POWER_ON)
}),
("power_on", |r, d| r.power_on(BayUid::new(d, 2))),
("set_volume", |r, d| {
r.set_volume(BayUid::new(d, 2), 40, None)
}),
("set_amp_zone_settings", |r, d| {
r.set_amp_zone_settings(BayUid::new(d, 2), AmpZoneSettings::default())
}),
("subscribe_v2ip_stats", |r, d| {
r.subscribe_v2ip_stats(d, true)
}),
("set_audio_endpoint_muted", |r, d| {
r.set_audio_endpoint_muted(d, 1, true)
}),
("select_audio_endpoint_input", |r, d| {
r.select_audio_endpoint_input(d, 1, d, 2)
}),
("set_multiviewer_view_mode", |r, d| {
r.set_multiviewer_view_mode(d, MultiviewerViewMode::PIP)
}),
("set_multiviewer_video_source", |r, d| {
r.set_multiviewer_video_source(d, 1, MultiviewerSource::from_wire(2))
}),
("multiviewer_auto_route", |r, d| r.multiviewer_auto_route(d)),
];
#[test]
fn every_guarded_send_checks_the_protocol_floor() {
let f = Fixture::new();
let old = uid_n(161);
f.everything(old, 0x01, "OLD0001");
for (name, call) in GUARDED_SENDS {
let got = call(&f.remote, old);
assert!(refused(&got), "0x01 device, {name}: {got:?}");
}
assert!(!refused(&f.remote.reboot(old)));
let current = uid_n(162);
f.everything(current, 0x28, "NEW0001");
for (name, call) in GUARDED_SENDS {
assert!(
!refused(&call(&f.remote, current)),
"0x28 device, {name}: refused, but it is above every floor here"
);
}
}
#[test]
fn a_device_exactly_on_a_floor_is_allowed() {
let f = Fixture::new();
let uid = uid_n(164);
f.everything(uid, 0x11, "EDGE001");
assert!(!refused(&f.remote.set_volume(
BayUid::new(uid, 2),
40,
None
)));
assert!(refused(&f.remote.subscribe_v2ip_stats(uid, true)));
}
#[test]
fn the_gate_sits_on_the_transmit_path_itself() {
let f = Fixture::new();
let (old, current) = (uid_n(171), uid_n(172));
f.amplifier(old, 0x01, "OLD1");
f.amplifier(current, 0x28, "NEW1");
let addressee = |uid: DeviceUid| {
f.remote
.shared
.read(|state| Addressee::device(state.device(uid).expect("device not registered")))
};
let send = |to: Addressee| {
f.remote
.shared
.send(&to, op::AMP_ZONE_SETTINGS, &[0u8; 56])
.map(|_| ())
.map_err(ControlError::Send)
};
assert!(refused(&send(addressee(old))));
assert!(!refused(&send(addressee(current))));
assert!(!refused(&send(Addressee::Broadcast)));
}
#[test]
fn a_built_amp_zone_frame_decodes_to_what_was_built() {
let f = Fixture::new();
let sender = uid_n(181);
f.everything(sender, 0x28, "RT0001");
let want = AmpZoneSettings {
gain_left: 190,
gain_right: 191,
volume_min: 12,
volume_max: 220,
delay_left: 96000,
delay_right: 144000,
bass: 130,
treble: 131,
bridged: 1,
power_mode: 2,
power_level: 33,
power_timeout: 900,
eq_left: [120, 121, 122, 123, 124],
eq_right: [140, 141, 142, 143, 144],
};
f.feed(
sender,
op::AMP_ZONE_SETTINGS,
&build_amp_zone_settings(sender, 2, &want),
);
let got = f
.remote
.bay(BayUid::new(sender, 2))
.expect("no bay")
.amp_settings
.expect("what the builder produced did not decode at all");
assert_eq!(got, want);
}
#[test]
fn a_built_manual_switch_frame_decodes_to_what_was_built() {
let f = Fixture::new();
let sender = uid_n(183);
f.everything(sender, 0x28, "RT0002");
let payload = build_v2ip_manual_source_switch(
sender,
V2ipStreams {
video: StreamAddr {
ip: Ipv4Addr::new(239, 10, 0, 1),
port: V2IP_PORT_VIDEO,
},
audio: StreamAddr {
ip: Ipv4Addr::new(239, 10, 0, 2),
port: V2IP_PORT_AUDIO,
},
anc: StreamAddr {
ip: Ipv4Addr::new(239, 10, 0, 3),
port: V2IP_PORT_ANC,
},
},
Some(V2ipAudioFormat {
sample_rate: 96000,
channels: 6,
}),
);
f.feed(sender, op::V2IP_MANUAL_SRC_SWITCH, &payload);
let sink = f
.remote
.v2ip_sink(sender)
.expect("what the builder produced did not decode at all");
for (what, got, ip, port) in [
(
"video",
sink.addresses.video,
Ipv4Addr::new(239, 10, 0, 1),
V2IP_PORT_VIDEO,
),
(
"audio",
sink.addresses.audio,
Ipv4Addr::new(239, 10, 0, 2),
V2IP_PORT_AUDIO,
),
(
"anc",
sink.addresses.anc,
Ipv4Addr::new(239, 10, 0, 3),
V2IP_PORT_ANC,
),
] {
assert_eq!((what, got.ip, got.port), (what, ip, port));
}
let format = sink.audio_fmt.expect("no audio format");
assert_eq!((format.sample_rate, format.channels), (96000, 6));
}
#[derive(Default)]
struct Requests {
names: Mutex<Vec<BayNameChange>>,
profiles: Mutex<Vec<EdidProfileChange>>,
actions: Mutex<Vec<ActionTransmitRequest>>,
}
impl EventHandler for Requests {
fn on_bay_name_change_requested(&self, _device: DeviceUid, change: BayNameChange) {
self.names.lock().expect("test handler").push(change);
}
fn on_edid_profile_change_requested(&self, _device: DeviceUid, change: EdidProfileChange) {
self.profiles.lock().expect("test handler").push(change);
}
fn on_action_transmit_requested(&self, _device: DeviceUid, request: ActionTransmitRequest) {
self.actions.lock().expect("test handler").push(request);
}
}
#[test]
fn a_control_method_decodes_back_to_what_it_asked_for() {
let seen = Arc::new(Requests::default());
let f = Fixture::with_handler(Arc::clone(&seen) as Arc<dyn EventHandler>);
let (target, peer) = (uid_n(191), uid_n(192));
f.everything(target, 0x28, "RT0003");
f.everything(peer, 0x28, "RT0004");
let round_trip = |what: &str, call| f.round_trip(what, peer, call);
let out = BayUid::new(target, 2);
let input = BayUid::new(target, 1);
f.tap.clear();
round_trip("set_bay_name", f.remote.set_bay_name(out, "Kitchen Amp"));
let name = seen
.names
.lock()
.expect("test handler")
.pop()
.expect("none");
assert_eq!(name.target, target);
assert_eq!(name.port, 2);
assert_eq!(name.name, "Kitchen Amp");
round_trip(
"select_edid_profile",
f.remote
.select_edid_profile(input, EdidProfile::HDR_SURROUND71_4K),
);
let profile = seen
.profiles
.lock()
.expect("test handler")
.pop()
.expect("none");
assert_eq!(profile.target, target);
assert_eq!(profile.profile, EdidProfile::HDR_SURROUND71_4K);
round_trip("send_action", f.remote.send_action(out, RcAction::POWER_ON));
let action = seen
.actions
.lock()
.expect("test handler")
.pop()
.expect("none");
assert_eq!(action.target, target);
assert_eq!(action.local_bay, 2);
assert_eq!(action.action, RcAction::POWER_ON);
round_trip("set_bay_hidden", f.remote.set_bay_hidden(out, true));
assert_eq!(
f.remote.bay(out).expect("no bay").hidden,
Some(true),
"hidden did not round trip onto the addressed bay"
);
round_trip("set_volume", f.remote.set_volume(out, 37, Some(false)));
let peer_out = f.remote.bay(BayUid::new(peer, 2)).expect("no peer bay");
assert_eq!(
peer_out.volume.and_then(|v| v.volume_left),
Some(37),
"volume did not round trip onto the sender's bay"
);
assert_ne!(
f.remote
.bay(out)
.expect("no bay")
.volume
.and_then(|v| v.volume_left),
Some(37),
"volume also landed on the addressed bay; this handler is sender-keyed"
);
}
#[test]
fn a_multiviewer_frame_is_stamped_above_its_opcodes_floor() {
let f = Fixture::new();
let uid = uid_n(196);
f.everything(uid, 0x28, "MV0001");
f.tap.clear();
let _ = f
.remote
.set_multiviewer_view_mode(uid, MultiviewerViewMode::PIP);
let frame = f.tap.frames().pop().expect("nothing reached the gate");
let stamped = u16::from_le_bytes([frame[2], frame[3]]);
assert_eq!(stamped, 0x20);
assert!(stamped > protocol_for(op::V2IP_MULTIVIEWER));
assert_eq!(frame.len(), HEADER_LEN + 25);
}
#[test]
fn a_write_back_waits_for_the_send() {
let f = Fixture::new();
let (old, current) = (uid_n(198), uid_n(199));
f.everything(old, 0x01, "WB0001");
f.everything(current, 0x28, "WB0002");
let refused_bay = BayUid::new(old, 2);
assert!(refused(&f.remote.set_bay_name(refused_bay, "Kitchen")));
assert!(refused(&f.remote.set_bay_hidden(refused_bay, true)));
let bay = f.remote.bay(refused_bay).expect("no bay");
assert_eq!(
bay.user_name, "TV",
"a refused rename still renamed the bay"
);
assert_eq!(bay.hidden, Some(false), "a refused hide still hid the bay");
f.connect();
let sent_bay = BayUid::new(current, 2);
f.remote
.set_bay_name(sent_bay, "Kitchen")
.expect("the socket is open and the device is above the floor");
f.remote
.set_bay_hidden(sent_bay, true)
.expect("the socket is open and the device is above the floor");
let bay = f.remote.bay(sent_bay).expect("no bay");
assert_eq!(bay.user_name, "Kitchen", "the rename was not written back");
assert_eq!(bay.hidden, Some(true), "the hide was not written back");
}
#[test]
fn a_source_switch_names_one_stream_and_zeroes_the_other() {
let f = Fixture::new();
let (video, audio, peer) = (uid_n(193), uid_n(194), uid_n(195));
f.everything(video, 0x28, "SW0001");
f.everything(audio, 0x28, "SW0002");
f.everything(peer, 0x28, "SW0003");
f.tap.clear();
f.round_trip(
"select_video_source",
peer,
f.remote.select_video_source(BayUid::new(video, 2), 1),
);
assert_eq!(
f.remote
.bay(BayUid::new(video, 2))
.expect("no bay")
.video_source,
Some(BayUid::new(video, 1)),
"the video group did not reach the video slot"
);
f.round_trip(
"select_audio_source",
peer,
f.remote.select_audio_source(BayUid::new(audio, 2), 1),
);
let bay = f.remote.bay(BayUid::new(audio, 2)).expect("no bay");
assert_eq!(
bay.audio_source,
Some(BayUid::new(audio, 1)),
"the audio group did not resolve back to the bay advertising it"
);
assert_eq!(
bay.video_source, None,
"the audio group reached the video slot as well"
);
}
#[derive(Default)]
struct AudioSelects(Mutex<Vec<AudioChangeSource>>);
impl EventHandler for AudioSelects {
fn on_audio_select_input(&self, _device: DeviceUid, change: AudioChangeSource) {
self.0.lock().expect("test handler").push(change);
}
}
#[test]
fn an_endpoint_selection_tells_its_source_from_its_sink() {
let seen = Arc::new(AudioSelects::default());
let f = Fixture::with_handler(Arc::clone(&seen) as Arc<dyn EventHandler>);
let (sink, source, peer) = (uid_n(186), uid_n(187), uid_n(188));
f.everything(sink, 0x28, "EP0001");
f.everything(source, 0x28, "EP0002");
f.everything(peer, 0x28, "EP0003");
f.tap.clear();
f.round_trip(
"select_audio_endpoint_input",
peer,
f.remote.select_audio_endpoint_input(sink, 5, source, 9),
);
let change = seen
.0
.lock()
.expect("test handler")
.pop()
.expect("what the builder produced did not decode at all");
assert_eq!(change.target_uid, sink, "the sink is not the listening end");
assert_eq!(
change.source_uid, source,
"the source is not the end being listened to"
);
assert_eq!((change.target_id, change.source_id), (5, 9));
}
#[test]
fn a_multiviewer_command_carries_its_sub_opcode_and_parameters() {
let f = Fixture::new();
let uid = uid_n(197);
f.everything(uid, 0x28, "MV0002");
let mapped = uid_n(60);
let mut config_source = mapped.as_bytes().to_vec();
config_source.push(3);
config_source.extend_from_slice(&[0; 7]);
type Call = fn(&Remote, DeviceUid) -> Result<(), ControlError>;
let calls: Vec<(&str, u8, Vec<u8>, Call)> = vec![
(
"view_mode",
1,
vec![MultiviewerViewMode::PIP.to_wire()],
|r, d| r.set_multiviewer_view_mode(d, MultiviewerViewMode::PIP),
),
("video_source", 2, vec![1, 2], |r, d| {
r.set_multiviewer_video_source(d, 1, MultiviewerSource::from_wire(2))
}),
("audio_source", 3, vec![1], |r, d| {
r.set_multiviewer_audio_source(d, MultiviewerSource::from_wire(2))
}),
("audio_volume", 4, vec![70, 1], |r, d| {
r.set_multiviewer_audio_volume(d, 70, true)
}),
("remote_control", 6, vec![1], |r, d| {
r.set_multiviewer_remote_control(d, MultiviewerSource::from_wire(2))
}),
("input_source", 14, config_source, |r, d| {
r.set_multiviewer_input_source(d, 3, uid_n(60))
}),
("auto_route", 15, vec![], |r, d| r.multiviewer_auto_route(d)),
];
for (name, sub, args, call) in calls {
f.tap.clear();
let _ = call(&f.remote, uid);
let frame = f
.tap
.frames()
.pop()
.unwrap_or_else(|| panic!("{name} reached no frame"));
let payload = frame
.get(HEADER_LEN..)
.expect("a frame shorter than a header");
assert_eq!(&payload[..16], &uid.as_bytes()[..], "{name}: wrong target");
assert_eq!(payload[16], sub, "{name}: wrong sub-opcode");
assert_eq!(
&payload[17..24],
&[0; 7],
"{name}: the padding carries data"
);
assert_eq!(&payload[24..], &args[..], "{name}: wrong parameters");
}
}
fn linked_to_amplifier() -> (Fixture, BayUid, BayUid) {
let f = Fixture::new();
let amp = uid_n(0x40);
let oneip = uid_n(0x41);
f.amplifier(amp, PROTOCOL_VERSION, "AMP00001");
f.feed(
oneip,
op::SYS_HELLO,
&hello_payload(
PROTOCOL_VERSION,
"OneIP",
"ONE00001",
"4.8.0",
DeviceFeature::VIDEO_ROUTING,
),
);
f.feed(
oneip,
op::SYS_BAY_CONFIG,
&bay_config_rec(
1,
1,
0,
"Output 1",
"Living room",
BayStatus::NONE,
BayFeatures::HDMI_OUT,
),
);
let mut record = vec![0u8; 38];
record[0] = 1;
record[2..10].copy_from_slice(b"AMP00001");
record[18..24].copy_from_slice(b"Zone 1");
f.feed(oneip, op::SYS_LINKS, &record);
f.tap.clear();
(f, BayUid::new(oneip, 1), BayUid::new(amp, 1))
}
#[test]
fn a_linked_output_reads_the_volume_of_the_zone_it_is_wired_to() {
let (f, output, zone) = linked_to_amplifier();
f.feed(zone.device, op::AUDIO_VOLUME_MUTE, &[1, 51, 51, 0]);
let info = f.remote.bay(output).expect("the output vanished");
assert_eq!(
info.linked_bay,
Some(zone),
"the output does not name the zone it is linked to"
);
assert_eq!(
info.volume.map(|v| v.volume()),
Some(51),
"the output reads no volume, so the link was not followed"
);
assert_eq!(
f.remote
.bay(zone)
.and_then(|b| b.volume)
.map(|v| v.volume()),
Some(51),
"the zone lost its own volume"
);
}
#[test]
fn setting_a_linked_output_addresses_the_zone_it_is_wired_to() {
let (f, output, zone) = linked_to_amplifier();
f.feed(zone.device, op::AUDIO_VOLUME_MUTE, &[1, 51, 51, 0]);
f.tap.clear();
let call = f.remote.set_volume(output, 40, Some(false));
assert!(
matches!(call, Err(ControlError::Send(SendError::NotConnected))),
"setting the linked output was refused rather than sent: {call:?}"
);
let frame = f.tap.frames().pop().expect("no frame was built");
let payload = frame
.get(HEADER_LEN..)
.expect("a frame shorter than a header");
assert_eq!(
&payload[..16],
&zone.device.as_bytes()[..],
"the frame names the output's device, not the amplifier"
);
assert_eq!(
u16::from_le_bytes([payload[16], payload[17]]),
zone.port,
"the frame names the output's port, not the zone's"
);
}
#[test]
fn stepping_a_linked_output_starts_from_the_zone_volume() {
let (f, output, zone) = linked_to_amplifier();
f.feed(zone.device, op::AUDIO_VOLUME_MUTE, &[1, 51, 51, 0]);
f.tap.clear();
let _ = f.remote.volume_up(output);
let frame = f.tap.frames().pop().expect("no frame was built");
let payload = frame
.get(HEADER_LEN..)
.expect("a frame shorter than a header");
assert_eq!(payload[18], 52, "the step did not start from the zone's 51");
}
#[test]
fn a_bay_with_its_own_volume_control_is_not_redirected() {
let (f, _, zone) = linked_to_amplifier();
f.feed(zone.device, op::AUDIO_VOLUME_MUTE, &[1, 51, 51, 0]);
f.tap.clear();
let call = f.remote.set_volume(zone, 40, Some(false));
assert!(
matches!(call, Err(ControlError::Send(SendError::NotConnected))),
"setting the zone was refused: {call:?}"
);
let frame = f.tap.frames().pop().expect("no frame was built");
let payload = frame
.get(HEADER_LEN..)
.expect("a frame shorter than a header");
assert_eq!(
&payload[..16],
&zone.device.as_bytes()[..],
"the zone's own volume was redirected somewhere else"
);
}
fn dolby_output(input_bay: u8) -> BayFeatures {
BayFeatures::from_bits(
BayFeatures::AUDIO_AMP_OUT.bits()
| BayFeatures::DOLBY.bits()
| (u32::from(input_bay) << 24),
)
}
fn dolby_amplifier(mode: u8) -> (Fixture, DeviceUid) {
let f = Fixture::new();
let amp = uid_n(0x50);
f.feed(
amp,
op::SYS_HELLO,
&hello_payload(
PROTOCOL_VERSION,
"ProAmp8",
"AMP00002",
"4.8.0",
DeviceFeature::AUDIO_AMPLIFIER
| DeviceFeature::VOLUME_CONTROL
| DeviceFeature::AUDIO_ROUTING,
),
);
let mut bays = Vec::new();
for zone in 0..4u8 {
bays.extend(bay_config_rec(
9 + zone,
1,
zone,
&format!("Output {}", zone + 1),
"Dolby zone",
BayStatus::NONE,
dolby_output(8),
));
}
bays.extend(bay_config_rec(
13,
1,
4,
"Output 5",
"Kitchen",
BayStatus::NONE,
BayFeatures::AUDIO_AMP_OUT,
));
bays.extend(bay_config_rec(
17,
1,
8,
"Output 9",
"Line out",
BayStatus::NONE,
dolby_output(8),
));
f.feed(amp, op::SYS_BAY_CONFIG, &bays);
let mut dolby = vec![0u8; 18];
dolby[..16].copy_from_slice(amp.as_bytes());
dolby[16] = mode;
f.feed(amp, op::AMP_DOLBY_STATE, &dolby);
f.tap.clear();
(f, amp)
}
fn zone_volumes(f: &Fixture, amp: DeviceUid, ports: &[u16]) -> Vec<Option<u8>> {
ports
.iter()
.map(|port| {
f.remote
.bay(BayUid::new(amp, *port))
.and_then(|b| b.volume)
.map(|v| v.volume())
})
.collect()
}
#[test]
fn a_four_zone_dolby_group_all_read_the_volume_reported_for_its_first_zone() {
let (f, amp) = dolby_amplifier(2);
f.feed(amp, op::AUDIO_VOLUME_MUTE, &[9, 51, 51, 0]);
assert_eq!(
zone_volumes(&f, amp, &[9, 10, 11, 12]),
vec![Some(51); 4],
"the four zones do not share the volume the amplifier reported"
);
assert_eq!(
zone_volumes(&f, amp, &[13, 17]),
vec![None, None],
"the volume reached an output outside the group"
);
}
#[test]
fn a_three_zone_dolby_group_stops_at_its_third_zone() {
let (f, amp) = dolby_amplifier(1);
f.feed(amp, op::AUDIO_VOLUME_MUTE, &[9, 51, 51, 0]);
assert_eq!(
zone_volumes(&f, amp, &[9, 10, 11]),
vec![Some(51); 3],
"the three zones do not share the volume the amplifier reported"
);
assert_eq!(
zone_volumes(&f, amp, &[12, 13, 17]),
vec![None, None, None],
"the fourth output is not in a three-zone group"
);
}
#[test]
fn an_amplifier_out_of_dolby_mode_groups_nothing() {
let (f, amp) = dolby_amplifier(0);
f.feed(amp, op::AUDIO_VOLUME_MUTE, &[9, 51, 51, 0]);
assert_eq!(
zone_volumes(&f, amp, &[9, 10, 11, 12]),
vec![Some(51), None, None, None],
"a standard-mode amplifier drives its outputs separately"
);
}
#[test]
fn a_volume_against_a_later_zone_is_not_spread_over_the_group() {
let (f, amp) = dolby_amplifier(2);
f.feed(amp, op::AUDIO_VOLUME_MUTE, &[11, 42, 42, 0]);
assert_eq!(
zone_volumes(&f, amp, &[9, 10, 11, 12]),
vec![None, None, Some(42), None],
"a report against a later zone was spread over the group"
);
}