Ability to register bonob service with sonos via button

This commit is contained in:
simojenki
2021-01-31 19:02:03 +11:00
parent 6f161abd95
commit 2ed2fce280
10 changed files with 742 additions and 316 deletions

View File

@@ -37,7 +37,8 @@ docker run \
item | default value | description
---- | ------------- | -----------
PORT | 4534 | Default http port for bonob to listen on
BONOB_PORT | 4534 | Default http port for bonob to listen on
BONOB_WEB_ADDRESS | http://localhost:4534 | Web address for bonob
BONOB_SONOS_SEED_HOST | undefined | sonos device seed host for auto-discovery, or 'disabled' to turn off device discovery entirely
BONOB_SONOS_SERVICE_NAME | bonob | service name for sonos
BONOS_SONOS_SERVICE_ID | 246 | service id for sonos

View File

@@ -10,12 +10,15 @@
"@types/express": "^4.17.11",
"@types/node": "^14.14.22",
"@types/underscore": "1.10.24",
"@types/uuid": "^8.3.0",
"axios": "^0.21.1",
"eta": "^1.12.1",
"express": "^4.17.1",
"node-html-parser": "^2.1.0",
"ts-md5": "^1.2.7",
"typescript": "^4.1.3",
"underscore":"^1.12.0",
"uuid": "^8.3.2",
"winston": "^3.3.3"
},
"devDependencies": {

View File

@@ -1,20 +1,19 @@
import sonos, { bonobService } from "./sonos";
import server from "./server";
import logger from "./logger"
import logger from "./logger";
const PORT = process.env["PORT"] || 4534;
const PORT = process.env["BONOB_PORT"] || 4534;
const WEB_ADDRESS = process.env["BONOB_WEB_ADDRESS"] || `http://localhost:${PORT}`;
const bonob = bonobService(
process.env["BONOB_SONOS_SERVICE_NAME"] || "bonob",
Number(process.env["BONOS_SONOS_SERVICE_ID"] || "246")
)
const app = server(
sonos(process.env["BONOB_SONOS_SEED_HOST"]),
bonob
Number(process.env["BONOS_SONOS_SERVICE_ID"] || "246"),
WEB_ADDRESS
);
const app = server(sonos(process.env["BONOB_SONOS_SEED_HOST"]), bonob);
app.listen(PORT, () => {
logger.info(`Listening on ${PORT}`);
logger.info(`Listening on ${PORT} available @ ${WEB_ADDRESS}`);
});
export default app;

View File

@@ -1,8 +1,8 @@
import express, { Express } from "express";
import * as Eta from "eta";
import { Sonos, servicesFrom, registrationStatus, Service } from "./sonos";
import { Sonos, Service } from "./sonos";
function server(sonos: Sonos, bonob: Service): Express {
function server(sonos: Sonos, bonobService: Service): Express {
const app = express();
app.use(express.static("./web/public"));
@@ -12,15 +12,25 @@ function server(sonos: Sonos, bonob: Service): Express {
app.set("views", "./web/views");
app.get("/", (_, res) => {
sonos.devices().then((devices) => {
const services = servicesFrom(devices);
Promise.all([
sonos.devices(),
sonos.services()
]).then(([devices, services]) => {
const registeredBonobService = services.find(it => it.sid == bonobService.sid);
res.render("index", {
devices,
services,
bonob,
registration: registrationStatus(services, bonob),
bonobService,
registeredBonobService
});
});
})
});
app.post("/register", (_, res) => {
sonos.register(bonobService).then(success => {
if(success) res.send("Yay")
else res.send("boo hoo")
})
});
return app;

View File

@@ -1,6 +1,8 @@
import { SonosManager, SonosDevice } from "@svrooij/sonos";
// import { MusicService } from "@svrooij/sonos/lib/services";
import { sortBy, uniq } from "underscore";
import axios from "axios";
import { parse } from "node-html-parser";
import { MusicService } from "@svrooij/sonos/lib/services";
import { head } from "underscore";
import logger from "./logger";
export type Device = {
@@ -8,60 +10,95 @@ export type Device = {
group: string;
ip: string;
port: number;
services: Service[];
};
export type Service = {
name: string;
id: number;
sid: number;
uri: string;
secureUri: string;
strings: { uri?: string; version?: string };
presentation: { uri?: string; version?: string };
pollInterval?: number;
authType: string;
};
export type BonobRegistrationStatus = 'registered' | 'not-registered'
const stripTailingSlash = (url: string) =>
url.endsWith("/") ? url.substring(0, url.length - 1) : url;
export const bonobService = (name: string, id: number): Service => ({
export const bonobService = (
name: string,
sid: number,
bonobRoot: string
): Service => ({
name,
id
})
sid,
uri: `${stripTailingSlash(bonobRoot)}/ws/sonos`,
secureUri: `${stripTailingSlash(bonobRoot)}/ws/sonos`,
strings: {
uri: `${stripTailingSlash(bonobRoot)}/sonos/strings.xml`,
version: "1",
},
presentation: {
uri: `${stripTailingSlash(bonobRoot)}/sonos/presentationMap.xml`,
version: "1",
},
pollInterval: 1200,
authType: "Anonymous",
});
export interface Sonos {
devices: () => Promise<Device[]>;
services: () => Promise<Service[]>;
register: (service: Service) => Promise<boolean>;
}
export const SONOS_DISABLED: Sonos = {
devices: () => Promise.resolve([]),
services: () => Promise.resolve([]),
register: (_: Service) => Promise.resolve(false),
};
export const servicesFrom = (devices: Device[]) =>
sortBy(
uniq(
devices.flatMap((d) => d.services),
false,
(s) => s.id
),
"name"
);
export const asService = (musicService: MusicService): Service => ({
name: musicService.Name,
sid: musicService.Id,
uri: musicService.Uri,
secureUri: musicService.SecureUri,
strings: {
uri: musicService.Presentation?.Strings?.Uri,
version: musicService.Presentation?.Strings?.Version,
},
presentation: {
uri: musicService.Presentation?.PresentationMap?.Uri,
version: musicService.Presentation?.PresentationMap?.Version,
},
pollInterval: musicService.Policy.PollInterval,
authType: musicService.Policy.Auth,
});
export const registrationStatus = (services: Service[], bonob: Service): BonobRegistrationStatus => {
if(services.find(s => s.id == bonob.id) != undefined) {
return "registered"
} else {
return "not-registered"
}
}
export const asDevice = (sonosDevice: SonosDevice): Device => ({
name: sonosDevice.Name,
group: sonosDevice.GroupName || "",
ip: sonosDevice.Host,
port: sonosDevice.Port,
});
export const asDevice = (sonosDevice: SonosDevice): Promise<Device> =>
sonosDevice.MusicServicesService.ListAndParseAvailableServices().then(
(services) => ({
name: sonosDevice.Name,
group: sonosDevice.GroupName || "",
ip: sonosDevice.Host,
port: sonosDevice.Port,
services: services.map((s) => ({
name: s.Name,
id: s.Id,
})),
})
);
export const asCustomdForm = (csrfToken: string, service: Service) => ({
csrfToken,
sid: `${service.sid}`,
name: service.name,
uri: service.uri,
secureUri: service.secureUri,
pollInterval: `${service.pollInterval || 1200}`,
authType: service.authType,
stringsVersion: service.strings.version || "",
stringsUri: service.strings.uri || "",
presentationMapVersion: service.presentation.version || "",
presentationMapUri: service.presentation.uri || "",
manifestVersion: "0",
manifestUri: "",
containerType: "MService",
});
const setupDiscovery = (
manager: SonosManager,
@@ -77,29 +114,73 @@ const setupDiscovery = (
};
export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
return {
devices: async () => {
const manager = new SonosManager();
return setupDiscovery(manager, sonosSeedHost)
.then((success) => {
if (success) {
const devices = Promise.all(manager.Devices.map(asDevice));
logger.info({ devices });
return devices;
} else {
logger.warn("Didn't find any sonos devices!");
return [];
}
})
.catch((e) => {
logger.error(`Failed looking for sonos devices ${e}`);
const sonosDevices = async (): Promise<SonosDevice[]> => {
const manager = new SonosManager();
return setupDiscovery(manager, sonosSeedHost)
.then((success) => {
if (success) {
console.log("had success");
return manager.Devices;
} else {
logger.warn("Didn't find any sonos devices!");
return [];
});
}
})
.catch((e) => {
console.log("booom");
logger.error(`Failed looking for sonos devices ${e}`);
return [];
});
};
return {
devices: async () => sonosDevices().then((it) => it.map(asDevice)),
services: async () =>
sonosDevices()
.then((it) => head(it))
.then(
(device) =>
device?.MusicServicesService?.ListAndParseAvailableServices() || []
)
.then((it) => it.map(asService)),
register: async (service: Service) => {
const anyDevice = await sonosDevices().then((devices) => head(devices));
if (!anyDevice) {
logger.warn("Failed to find a device to register with...")
return false
};
const customd = `http://${anyDevice.Host}:${anyDevice.Port}/customsd`;
const csrfToken = await axios.get(customd).then((response) =>
parse(response.data)
.querySelectorAll("input")
.find((it) => it.getAttribute("name") == "csrfToken")
?.getAttribute("value")
);
if (!csrfToken) {
logger.warn(`Failed to find csrfToken at GET -> ${customd}, cannot register service`)
return false
};
return axios
.post(customd, new URLSearchParams(asCustomdForm(csrfToken, service)), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
})
.then((response) => response.status == 200);
},
};
}
export default function sonos(sonosSeedHost?: string): Sonos {
export default function sonos(
sonosSeedHost: string | undefined = undefined
): Sonos {
switch (sonosSeedHost) {
case "disabled":
logger.info("Sonos device discovery disabled");

48
tests/builders.ts Normal file
View File

@@ -0,0 +1,48 @@
import { SonosDevice } from "@svrooij/sonos/lib";
import { v4 as uuid } from 'uuid';
import { Service, Device } from "../src/sonos";
const randomInt = (max: number) => Math.floor(Math.random() * max)
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`
export const aService = (fields: Partial<Service> = {}): Service => ({
name: `Test Music Service ${uuid()}`,
sid: randomInt(500),
uri: "https://sonos.testmusic.com/",
secureUri: "https://sonos.testmusic.com/",
strings: {
uri: "https://sonos.testmusic.com/strings.xml",
version: "22",
},
presentation: {
uri: "https://sonos.testmusic.com/presentation.xml",
version: "33",
},
pollInterval: 1200,
authType: "DeviceLink",
...fields,
});
export function aDevice(fields: Partial<Device> = {}): Device {
return {
name: `device-${uuid()}`,
group: `group-${uuid()}`,
ip: randomIpAddress(),
port: randomInt(10_000),
...fields,
};
}
export function aSonosDevice(
fields: Partial<SonosDevice> = {}
): SonosDevice {
return {
Name: `device-${uuid()}`,
GroupName: `group-${uuid()}`,
Host: randomIpAddress(),
Port: randomInt(10_000),
...fields,
} as SonosDevice;
}

View File

@@ -1,15 +1,12 @@
import request from "supertest";
import makeServer from "../src/server";
import { SONOS_DISABLED, Sonos, Device, Service } from "../src/sonos";
import { SONOS_DISABLED, Sonos, Device } from "../src/sonos";
import { aDevice, aService } from './builders';
describe("index", () => {
const BONOB_FOR_TEST: Service = {
name: "test bonob",
id: 999
}
describe("when sonos integration is disabled", () => {
const server = makeServer(SONOS_DISABLED, BONOB_FOR_TEST);
const server = makeServer(SONOS_DISABLED, aService());
describe("devices list", () => {
it("should be empty", async () => {
@@ -22,57 +19,54 @@ describe("index", () => {
});
describe("when there are 2 devices and bonob is not registered", () => {
const device1 : Device = {
const service1 = aService({
name: "s1",
sid: 1,
});
const service2 = aService({
name: "s2",
sid: 2,
});
const service3 = aService({
name: "s3",
sid: 3,
});
const service4 = aService({
name: "s4",
sid: 4,
});
const missingBonobService = aService({
name: "bonobMissing",
sid: 88
})
const device1: Device = aDevice({
name: "device1",
group: "group1",
ip: "172.0.0.1",
port: 4301,
services: [
{
name: "s1",
id: 1,
},
{
name: "s2",
id: 2,
},
],
};
});
const device2: Device = {
const device2: Device = aDevice({
name: "device2",
group: "group2",
ip: "172.0.0.2",
port: 4302,
services: [
{
name: "s3",
id: 3,
},
{
name: "s4",
id: 4,
},
],
}
});
const fakeSonos: Sonos = {
devices: () =>Promise.resolve([device1, device2]),
devices: () => Promise.resolve([device1, device2]),
services: () => Promise.resolve([service1, service2, service3, service4]),
register: () => Promise.resolve(false),
};
const server = makeServer(fakeSonos, BONOB_FOR_TEST);
const server = makeServer(fakeSonos, missingBonobService);
describe("devices list", () => {
it("should contain the devices returned from sonos", async () => {
const res = await request(server).get("/").send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(
/device1\s+\(172.0.0.1:4301\)/
);
expect(res.text).toMatch(
/device2\s+\(172.0.0.2:4302\)/
);
expect(res.text).toMatch(/device1\s+\(172.0.0.1:4301\)/);
expect(res.text).toMatch(/device2\s+\(172.0.0.2:4302\)/);
});
});
@@ -93,64 +87,35 @@ describe("index", () => {
it("should be not-registered", async () => {
const res = await request(server).get("/").send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(
/test bonob\s+\(999\) is not-registered/
);
})
expect(res.text).toMatch(/No existing service registration/);
});
});
});
describe("when there are 2 devices and bonob is registered", () => {
const device1 : Device = {
name: "device1",
group: "group1",
ip: "172.0.0.1",
port: 4301,
services: [
{
name: "s1",
id: 1,
},
{
name: "s2",
id: 2,
},
BONOB_FOR_TEST
],
};
const service1 = aService();
const device2: Device = {
name: "device2",
group: "group2",
ip: "172.0.0.2",
port: 4302,
services: [
{
name: "s1",
id: 1,
},
{
name: "s4",
id: 4,
},
BONOB_FOR_TEST
],
}
const service2 = aService();
const bonobService = aService({
name: "bonobNotMissing",
sid: 99
})
const fakeSonos: Sonos = {
devices: () =>Promise.resolve([device1, device2]),
devices: () => Promise.resolve([]),
services: () => Promise.resolve([service1, service2, bonobService]),
register: () => Promise.resolve(false),
};
const server = makeServer(fakeSonos, BONOB_FOR_TEST);
const server = makeServer(fakeSonos, bonobService);
describe("registration status", () => {
it("should be registered", async () => {
const res = await request(server).get("/").send();
expect(res.status).toEqual(200);
expect(res.text).toMatch(
/test bonob\s+\(999\) is registered/
);
})
expect(res.text).toMatch(/Existing service config/);
});
});
});
});

View File

@@ -1,19 +1,28 @@
import { SonosManager, SonosDevice } from "@svrooij/sonos";
import { MusicServicesService } from "@svrooij/sonos/lib/services";
import { shuffle } from "underscore";
import {
MusicServicesService,
MusicService,
} from "@svrooij/sonos/lib/services";
jest.mock("@svrooij/sonos");
import axios from "axios";
jest.mock("axios");
import { v4 as uuid } from 'uuid';
import { AMAZON_MUSIC, APPLE_MUSIC, AUDIBLE } from "./music_services";
import sonos, {
SONOS_DISABLED,
asDevice,
Device,
servicesFrom,
registrationStatus,
asService,
asCustomdForm,
bonobService,
Service,
} from "../src/sonos";
import { aSonosDevice, aService } from "./builders";
const mockSonosManagerConstructor = <jest.Mock<SonosManager>>SonosManager;
describe("sonos", () => {
@@ -21,40 +30,54 @@ describe("sonos", () => {
mockSonosManagerConstructor.mockClear();
});
describe("bonobRegistrationStatus", () => {
describe("when bonob is registered", () => {
it("should return 'registered'", () => {
const bonob = {
name: "some bonob",
id: 123,
};
expect(
registrationStatus(
[
{ id: 1, name: "not bonob" },
bonob,
{ id: 2, name: "also not bonob" },
],
bonob
)
).toBe("registered");
});
});
describe("asService", () => {
it("should convert", () => {
const musicService: MusicService = {
Name: "Amazon Music",
Version: "1.1",
Uri: "https://sonos.amazonmusic.com/",
SecureUri: "https://sonos.amazonmusic.com/",
ContainerType: "MService",
Capabilities: "2208321",
Presentation: {
Strings: {
Version: "23",
Uri: "https://sonos.amazonmusic.com/strings.xml",
},
PresentationMap: {
Version: "17",
Uri: "https://sonos.amazonmusic.com/PresentationMap.xml",
},
},
Id: 201,
Policy: { Auth: "DeviceLink", PollInterval: 60 },
Manifest: {
Uri: "",
Version: "",
},
};
describe("when bonob is not registered", () => {
it("should return not-registered", () => {
expect(
registrationStatus([{ id: 1, name: "not bonob" }], {
name: "bonob",
id: 999,
})
).toBe("not-registered");
expect(asService(musicService)).toEqual({
name: "Amazon Music",
sid: 201,
uri: "https://sonos.amazonmusic.com/",
secureUri: "https://sonos.amazonmusic.com/",
strings: {
uri: "https://sonos.amazonmusic.com/strings.xml",
version: "23",
},
presentation: {
uri: "https://sonos.amazonmusic.com/PresentationMap.xml",
version: "17",
},
pollInterval: 60,
authType: "DeviceLink",
});
});
});
describe("asDevice", () => {
it("should convert", async () => {
it("should convert", () => {
const musicServicesService = {
ListAndParseAvailableServices: jest.fn(),
};
@@ -71,57 +94,119 @@ describe("sonos", () => {
APPLE_MUSIC,
]);
expect(await asDevice(device)).toEqual({
expect(asDevice(device)).toEqual({
name: "d1",
group: "g1",
ip: "127.0.0.222",
port: 123,
services: [
{
name: AMAZON_MUSIC.Name,
id: AMAZON_MUSIC.Id,
},
{
name: APPLE_MUSIC.Name,
id: APPLE_MUSIC.Id,
},
],
});
});
});
function someDevice(params: Partial<Device> = {}): Device {
const device = {
name: "device123",
group: "",
ip: "127.0.0.11",
port: 123,
services: [],
};
return { ...device, ...params };
}
describe("servicesFrom", () => {
it("should only return uniq services, sorted by name", () => {
const service1 = { id: 1, name: "D" };
const service2 = { id: 2, name: "B" };
const service3 = { id: 3, name: "C" };
const service4 = { id: 4, name: "A" };
const d1 = someDevice({ services: shuffle([service1, service2]) });
const d2 = someDevice({
services: shuffle([service1, service2, service3]),
describe("bonobService", () => {
describe("when the bonob root does not have a trailing /", () => {
it("should return a valid bonob service", () => {
expect(
bonobService("some-bonob", 876, "http://bonob.example.com")
).toEqual({
name: "some-bonob",
sid: 876,
uri: `http://bonob.example.com/ws/sonos`,
secureUri: `http://bonob.example.com/ws/sonos`,
strings: {
uri: `http://bonob.example.com/sonos/strings.xml`,
version: "1",
},
presentation: {
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
version: "1",
},
pollInterval: 1200,
authType: "Anonymous",
});
});
const d3 = someDevice({ services: shuffle([service4]) });
});
const devices: Device[] = [d1, d2, d3];
describe("when the bonob root does have a trailing /", () => {
it("should return a valid bonob service", () => {
expect(
bonobService("some-bonob", 876, "http://bonob.example.com/")
).toEqual({
name: "some-bonob",
sid: 876,
uri: `http://bonob.example.com/ws/sonos`,
secureUri: `http://bonob.example.com/ws/sonos`,
strings: {
uri: `http://bonob.example.com/sonos/strings.xml`,
version: "1",
},
presentation: {
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
version: "1",
},
pollInterval: 1200,
authType: "Anonymous",
});
});
});
});
expect(servicesFrom(devices)).toEqual([
service4,
service2,
service3,
service1,
]);
describe("asCustomdForm", () => {
describe("when all values specified", () => {
it("should return a form", () => {
const csrfToken = uuid();
const service: Service = {
name: "the new service",
sid: 888,
uri: "http://aa.example.com",
secureUri: "https://aa.example.com",
strings: { uri: "http://strings.example.com", version: "26" },
presentation: {
uri: "http://presentation.example.com",
version: "27",
},
pollInterval: 5600,
authType: "SpecialAuth",
};
expect(asCustomdForm(csrfToken, service)).toEqual({
csrfToken,
sid: "888",
name: "the new service",
uri: "http://aa.example.com",
secureUri: "https://aa.example.com",
pollInterval: "5600",
authType: "SpecialAuth",
stringsVersion: "26",
stringsUri: "http://strings.example.com",
presentationMapVersion: "27",
presentationMapUri: "http://presentation.example.com",
manifestVersion: "0",
manifestUri: "",
containerType: "MService",
});
});
});
describe("when pollInterval undefined", () => {
it("should default to 1200", () => {
const service: Service = aService({ pollInterval: undefined });
expect(asCustomdForm(uuid(), service).pollInterval).toEqual("1200");
});
});
describe("when strings and presentation are undefined", () => {
it("should default to 1200", () => {
const service: Service = aService({
strings: { uri: undefined, version: undefined },
presentation: { uri: undefined, version: undefined },
});
const form = asCustomdForm(uuid(), service)
expect(form.stringsUri).toEqual("");
expect(form.stringsVersion).toEqual("");
expect(form.presentationMapUri).toEqual("");
expect(form.presentationMapVersion).toEqual("");
});
});
});
@@ -131,37 +216,26 @@ describe("sonos", () => {
expect(disabled).toEqual(SONOS_DISABLED);
expect(await disabled.devices()).toEqual([]);
expect(await disabled.services()).toEqual([]);
expect(await disabled.register(aService())).toEqual(false);
});
});
describe("sonos device discovery", () => {
const device1_MusicServicesService = {
ListAndParseAvailableServices: jest.fn(),
};
const device1 = {
Name: "device1",
GroupName: "group1",
Host: "127.0.0.11",
Port: 111,
MusicServicesService: (device1_MusicServicesService as unknown) as MusicServicesService,
} as SonosDevice;
const device2_MusicServicesService = {
ListAndParseAvailableServices: jest.fn(),
};
const device2 = {
Name: "device2",
GroupName: "group2",
Host: "127.0.0.22",
Port: 222,
MusicServicesService: (device2_MusicServicesService as unknown) as MusicServicesService,
} as SonosDevice;
beforeEach(() => {
device1_MusicServicesService.ListAndParseAvailableServices.mockClear();
device2_MusicServicesService.ListAndParseAvailableServices.mockClear();
});
describe("when no sonos seed host is provided", () => {
it("should perform auto-discovery", async () => {
const sonosManager = {
@@ -241,99 +315,323 @@ describe("sonos", () => {
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
device1_MusicServicesService.ListAndParseAvailableServices.mockResolvedValue(
[AMAZON_MUSIC, APPLE_MUSIC]
);
device2_MusicServicesService.ListAndParseAvailableServices.mockResolvedValue(
[AUDIBLE]
);
const actualDevices = await sonos(undefined).devices();
expect(
device1_MusicServicesService.ListAndParseAvailableServices
).toHaveBeenCalled();
expect(
device2_MusicServicesService.ListAndParseAvailableServices
).toHaveBeenCalled();
expect(actualDevices).toEqual([
{
name: device1.Name,
group: device1.GroupName,
ip: device1.Host,
port: device1.Port,
services: [
{
name: AMAZON_MUSIC.Name,
id: AMAZON_MUSIC.Id,
},
{
name: APPLE_MUSIC.Name,
id: APPLE_MUSIC.Id,
},
],
},
{
name: device2.Name,
group: device2.GroupName,
ip: device2.Host,
port: device2.Port,
services: [
{
name: AUDIBLE.Name,
id: AUDIBLE.Id,
},
],
},
]);
});
});
describe("when initialisation returns false", () => {
it("should return empty []", async () => {
const initialize = jest.fn();
describe("when SonosManager initialisation returns false", () => {
it("should return no devices", async () => {
const sonosManager = {
InitializeWithDiscovery: initialize as (
x: number
) => Promise<boolean>,
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
} as SonosManager;
};
mockSonosManagerConstructor.mockReturnValue(sonosManager);
initialize.mockResolvedValue(false);
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(false);
const actualDevices = await sonos("").devices();
expect(await sonos("").devices()).toEqual([]);
});
});
});
describe("sonos service discovery", () => {
const device1 = {
Name: "device1",
GroupName: "group1",
Host: "127.0.0.11",
Port: 111,
MusicServicesService: {
ListAndParseAvailableServices: jest.fn(),
},
};
const device2 = {
Name: "device2",
GroupName: "group2",
Host: "127.0.0.22",
Port: 222,
};
beforeEach(() => {
device1.MusicServicesService.ListAndParseAvailableServices.mockClear();
});
describe("when there are no devices", () => {
it("should return no services", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const services = await sonos().services();
expect(SonosManager).toHaveBeenCalledTimes(1);
expect(initialize).toHaveBeenCalledWith(10);
expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10);
expect(actualDevices).toEqual([]);
expect(services).toEqual([]);
});
});
describe("when there are some devices", () => {
it("should return the services from the first device", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
device1.MusicServicesService.ListAndParseAvailableServices.mockResolvedValue(
[AMAZON_MUSIC, APPLE_MUSIC, AUDIBLE]
);
const services = await sonos().services();
expect(SonosManager).toHaveBeenCalledTimes(1);
expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10);
expect(services).toEqual([
asService(AMAZON_MUSIC),
asService(APPLE_MUSIC),
asService(AUDIBLE),
]);
});
});
describe("when SonosManager initialisation returns false", () => {
it("should return no devices", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(false);
const services = await sonos().services();
expect(SonosManager).toHaveBeenCalledTimes(1);
expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10);
expect(services).toEqual([]);
});
});
describe("when getting devices fails", () => {
it("should return empty []", async () => {
const initialize = jest.fn();
const sonosManager = ({
InitializeWithDiscovery: initialize as (
x: number
) => Promise<boolean>,
it("should return no devices", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: () => {
throw Error("Boom");
},
} as unknown) as SonosManager;
};
mockSonosManagerConstructor.mockReturnValue(sonosManager);
initialize.mockResolvedValue(true);
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const actualDevices = await sonos("").devices();
const services = await sonos().services();
expect(SonosManager).toHaveBeenCalledTimes(1);
expect(initialize).toHaveBeenCalledWith(10);
expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10);
expect(actualDevices).toEqual([]);
expect(services).toEqual([]);
});
});
});
describe("registering a service", () => {
const device1 = aSonosDevice({
Name: "d1",
Host: "127.0.0.11",
Port: 111,
});
const device2 = aSonosDevice({
Name: "d2",
});
const POST_CONFIG = {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
};
const serviceToAdd = aService({
name: "new service",
sid: 123,
});
const mockGet = jest.fn();
const mockPost = jest.fn();
beforeEach(() => {
mockGet.mockClear();
mockPost.mockClear();
axios.get = mockGet;
axios.post = mockPost;
});
describe("when successful", () => {
it("should post the service into the first found sonos device, returning true", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const csrfToken = `csrfToken-${uuid()}`;
mockGet.mockResolvedValue({
status: 200,
data: `<html><input name='csrfToken' value='${csrfToken}'></html>`,
});
mockPost.mockResolvedValue({ status: 200, data: "" });
const result = await sonos().register(serviceToAdd);
expect(mockGet).toHaveBeenCalledWith(
`http://${device1.Host}:${device1.Port}/customsd`
);
expect(mockPost).toHaveBeenCalledWith(
`http://${device1.Host}:${device1.Port}/customsd`,
new URLSearchParams(asCustomdForm(csrfToken, serviceToAdd)),
POST_CONFIG
);
expect(result).toEqual(true);
});
});
describe("when cannot find any devices", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const result = await sonos().register(serviceToAdd);
expect(mockGet).not.toHaveBeenCalled();
expect(mockPost).not.toHaveBeenCalled();
expect(result).toEqual(false);
});
});
describe("when cannot get csrfToken", () => {
describe("when the token is missing", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
mockGet.mockResolvedValue({
status: 200,
data: `<html></html>`,
});
const result = await sonos().register(serviceToAdd);
expect(mockPost).not.toHaveBeenCalled();
expect(result).toEqual(false);
});
});
describe("when the token call returns a non 200", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
mockGet.mockResolvedValue({
status: 400,
data: `<html></html>`,
});
const result = await sonos().register(serviceToAdd);
expect(mockPost).not.toHaveBeenCalled();
expect(result).toEqual(false);
});
});
});
describe("when posting in the service definition fails", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const csrfToken = `csrfToken-${uuid()}`;
mockGet.mockResolvedValue({
status: 200,
data: `<html><input name='csrfToken' value='${csrfToken}'></html>`,
});
mockPost.mockResolvedValue({ status: 500, data: "" });
const result = await sonos().register(serviceToAdd);
expect(result).toEqual(false);
});
});
});

View File

@@ -2,7 +2,16 @@
<div id="content">
<h1>bonob</h1>
<h2><%= it.bonob.name %> (<%= it.bonob.id %>) is <%= it.registration %></h2>
<h2><%= it.bonobService.name %> (<%= it.bonobService.sid %>)
<h3>Expected config</h3>
<div><%= JSON.stringify(it.bonobService) %></div>
<% if(it.registeredBonobService) { %>
<h3>Existing service config</h3>
<div><%= JSON.stringify(it.registeredBonobService) %></div>
<% } else { %>
<h3>No existing service registration</h3>
<% } %>
<form action="/register" method="POST"><button>Re-register</button></form>
<h2>Devices</h2>
<ul>
<% it.devices.forEach(function(d){ %>
@@ -12,7 +21,7 @@
<h2>Services <%= it.services.length %></h2>
<ul>
<% it.services.forEach(function(s){ %>
<li><%= s.name %> (<%= s.id %>)</li>
<li><%= s.name %> (<%= s.sid %>)</li>
<% }) %>
</ul>
</div>

View File

@@ -691,6 +691,11 @@
resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.10.24.tgz#dede004deed3b3f99c4db0bdb9ee21cae25befdd"
integrity sha512-T3NQD8hXNW2sRsSbLNjF/aBo18MyJlbw0lSpQHB/eZZtScPdexN4HSa8cByYwTw9Wy7KuOFr81mlDQcQQaZ79w==
"@types/uuid@^8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f"
integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==
"@types/yargs-parser@*":
version "20.2.0"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.0.tgz#dd3e6699ba3237f0348cd085e4698780204842f9"
@@ -2165,6 +2170,11 @@ has@^1.0.3:
dependencies:
function-bind "^1.1.1"
he@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
hosted-git-info@^2.1.4:
version "2.8.8"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
@@ -3112,17 +3122,12 @@ locate-path@^5.0.0:
dependencies:
p-locate "^4.1.0"
lodash.memoize@4.x:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
lodash@^4.17.19, lodash@^4.17.5:
lodash@4.x, lodash@^4.17.19, lodash@^4.17.5:
version "4.17.20"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
@@ -3347,6 +3352,13 @@ node-fetch@^2.6.1:
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
node-html-parser@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-2.1.0.tgz#36345804d743a5a1f672d4821a53f6b0e60629a9"
integrity sha512-kbCNfqjrwHAbG+mevL8aqjwVtF0Qv66XurWHoGLOc5G9rPR1L3k602jfeczAUUBldLNnCrdsDmO5G5nqAoMW+g==
dependencies:
he "1.2.0"
node-int64@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
@@ -4513,9 +4525,9 @@ triple-beam@^1.2.0, triple-beam@^1.3.0:
integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
ts-jest@^26.4.4:
version "26.4.4"
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.4.4.tgz#61f13fb21ab400853c532270e52cc0ed7e502c49"
integrity sha512-3lFWKbLxJm34QxyVNNCgXX1u4o/RV0myvA2y2Bxm46iGIjKlaY0own9gIckbjZJPn+WaJEnfPPJ20HHGpoq4yg==
version "26.5.0"
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.5.0.tgz#3e3417d91bc40178a6716d7dacc5b0505835aa21"
integrity sha512-Ya4IQgvIFNa2Mgq52KaO8yBw2W8tWp61Ecl66VjF0f5JaV8u50nGoptHVILOPGoI7SDnShmEqnYQEmyHdQ+56g==
dependencies:
"@types/jest" "26.x"
bs-logger "0.x"
@@ -4523,7 +4535,7 @@ ts-jest@^26.4.4:
fast-json-stable-stringify "2.x"
jest-util "^26.1.0"
json5 "2.x"
lodash.memoize "4.x"
lodash "4.x"
make-error "1.x"
mkdirp "1.x"
semver "7.x"
@@ -4712,7 +4724,7 @@ uuid@^3.3.2:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
uuid@^8.3.0:
uuid@^8.3.0, uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==