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 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_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 BONOB_SONOS_SERVICE_NAME | bonob | service name for sonos
BONOS_SONOS_SERVICE_ID | 246 | service id for sonos BONOS_SONOS_SERVICE_ID | 246 | service id for sonos

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import { SonosManager, SonosDevice } from "@svrooij/sonos"; import { SonosManager, SonosDevice } from "@svrooij/sonos";
// import { MusicService } from "@svrooij/sonos/lib/services"; import axios from "axios";
import { sortBy, uniq } from "underscore"; import { parse } from "node-html-parser";
import { MusicService } from "@svrooij/sonos/lib/services";
import { head } from "underscore";
import logger from "./logger"; import logger from "./logger";
export type Device = { export type Device = {
@@ -8,60 +10,95 @@ export type Device = {
group: string; group: string;
ip: string; ip: string;
port: number; port: number;
services: Service[];
}; };
export type Service = { export type Service = {
name: string; 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, 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 { export interface Sonos {
devices: () => Promise<Device[]>; devices: () => Promise<Device[]>;
services: () => Promise<Service[]>;
register: (service: Service) => Promise<boolean>;
} }
export const SONOS_DISABLED: Sonos = { export const SONOS_DISABLED: Sonos = {
devices: () => Promise.resolve([]), devices: () => Promise.resolve([]),
services: () => Promise.resolve([]),
register: (_: Service) => Promise.resolve(false),
}; };
export const servicesFrom = (devices: Device[]) => export const asService = (musicService: MusicService): Service => ({
sortBy( name: musicService.Name,
uniq( sid: musicService.Id,
devices.flatMap((d) => d.services), uri: musicService.Uri,
false, secureUri: musicService.SecureUri,
(s) => s.id strings: {
), uri: musicService.Presentation?.Strings?.Uri,
"name" 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 => { export const asDevice = (sonosDevice: SonosDevice): Device => ({
if(services.find(s => s.id == bonob.id) != undefined) { name: sonosDevice.Name,
return "registered" group: sonosDevice.GroupName || "",
} else { ip: sonosDevice.Host,
return "not-registered" port: sonosDevice.Port,
} });
}
export const asDevice = (sonosDevice: SonosDevice): Promise<Device> => export const asCustomdForm = (csrfToken: string, service: Service) => ({
sonosDevice.MusicServicesService.ListAndParseAvailableServices().then( csrfToken,
(services) => ({ sid: `${service.sid}`,
name: sonosDevice.Name, name: service.name,
group: sonosDevice.GroupName || "", uri: service.uri,
ip: sonosDevice.Host, secureUri: service.secureUri,
port: sonosDevice.Port, pollInterval: `${service.pollInterval || 1200}`,
services: services.map((s) => ({ authType: service.authType,
name: s.Name, stringsVersion: service.strings.version || "",
id: s.Id, stringsUri: service.strings.uri || "",
})), presentationMapVersion: service.presentation.version || "",
}) presentationMapUri: service.presentation.uri || "",
); manifestVersion: "0",
manifestUri: "",
containerType: "MService",
});
const setupDiscovery = ( const setupDiscovery = (
manager: SonosManager, manager: SonosManager,
@@ -77,29 +114,73 @@ const setupDiscovery = (
}; };
export function autoDiscoverySonos(sonosSeedHost?: string): Sonos { export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
return { const sonosDevices = async (): Promise<SonosDevice[]> => {
devices: async () => { const manager = new SonosManager();
const manager = new SonosManager(); return setupDiscovery(manager, sonosSeedHost)
return setupDiscovery(manager, sonosSeedHost) .then((success) => {
.then((success) => { if (success) {
if (success) { console.log("had success");
const devices = Promise.all(manager.Devices.map(asDevice)); return manager.Devices;
logger.info({ devices }); } else {
return devices; logger.warn("Didn't find any sonos devices!");
} else {
logger.warn("Didn't find any sonos devices!");
return [];
}
})
.catch((e) => {
logger.error(`Failed looking for sonos devices ${e}`);
return []; 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) { switch (sonosSeedHost) {
case "disabled": case "disabled":
logger.info("Sonos device discovery 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 request from "supertest";
import makeServer from "../src/server"; 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", () => { describe("index", () => {
const BONOB_FOR_TEST: Service = {
name: "test bonob",
id: 999
}
describe("when sonos integration is disabled", () => { describe("when sonos integration is disabled", () => {
const server = makeServer(SONOS_DISABLED, BONOB_FOR_TEST); const server = makeServer(SONOS_DISABLED, aService());
describe("devices list", () => { describe("devices list", () => {
it("should be empty", async () => { it("should be empty", async () => {
@@ -22,57 +19,54 @@ describe("index", () => {
}); });
describe("when there are 2 devices and bonob is not registered", () => { 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", name: "device1",
group: "group1",
ip: "172.0.0.1", ip: "172.0.0.1",
port: 4301, port: 4301,
services: [ });
{
name: "s1",
id: 1,
},
{
name: "s2",
id: 2,
},
],
};
const device2: Device = { const device2: Device = aDevice({
name: "device2", name: "device2",
group: "group2",
ip: "172.0.0.2", ip: "172.0.0.2",
port: 4302, port: 4302,
services: [ });
{
name: "s3",
id: 3,
},
{
name: "s4",
id: 4,
},
],
}
const fakeSonos: Sonos = { 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", () => { describe("devices list", () => {
it("should contain the devices returned from sonos", async () => { it("should contain the devices returned from sonos", async () => {
const res = await request(server).get("/").send(); const res = await request(server).get("/").send();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.text).toMatch( expect(res.text).toMatch(/device1\s+\(172.0.0.1:4301\)/);
/device1\s+\(172.0.0.1:4301\)/ expect(res.text).toMatch(/device2\s+\(172.0.0.2:4302\)/);
);
expect(res.text).toMatch(
/device2\s+\(172.0.0.2:4302\)/
);
}); });
}); });
@@ -93,64 +87,35 @@ describe("index", () => {
it("should be not-registered", async () => { it("should be not-registered", async () => {
const res = await request(server).get("/").send(); const res = await request(server).get("/").send();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.text).toMatch( expect(res.text).toMatch(/No existing service registration/);
/test bonob\s+\(999\) is not-registered/ });
);
})
}); });
}); });
describe("when there are 2 devices and bonob is registered", () => { describe("when there are 2 devices and bonob is registered", () => {
const device1 : Device = { const service1 = aService();
name: "device1",
group: "group1",
ip: "172.0.0.1",
port: 4301,
services: [
{
name: "s1",
id: 1,
},
{
name: "s2",
id: 2,
},
BONOB_FOR_TEST
],
};
const device2: Device = { const service2 = aService();
name: "device2",
group: "group2", const bonobService = aService({
ip: "172.0.0.2", name: "bonobNotMissing",
port: 4302, sid: 99
services: [ })
{
name: "s1",
id: 1,
},
{
name: "s4",
id: 4,
},
BONOB_FOR_TEST
],
}
const fakeSonos: Sonos = { 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", () => { describe("registration status", () => {
it("should be registered", async () => { it("should be registered", async () => {
const res = await request(server).get("/").send(); const res = await request(server).get("/").send();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.text).toMatch( expect(res.text).toMatch(/Existing service config/);
/test bonob\s+\(999\) is registered/ });
);
})
}); });
}); });
}); });

View File

@@ -1,19 +1,28 @@
import { SonosManager, SonosDevice } from "@svrooij/sonos"; import { SonosManager, SonosDevice } from "@svrooij/sonos";
import { MusicServicesService } from "@svrooij/sonos/lib/services"; import {
import { shuffle } from "underscore"; MusicServicesService,
MusicService,
} from "@svrooij/sonos/lib/services";
jest.mock("@svrooij/sonos"); 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 { AMAZON_MUSIC, APPLE_MUSIC, AUDIBLE } from "./music_services";
import sonos, { import sonos, {
SONOS_DISABLED, SONOS_DISABLED,
asDevice, asDevice,
Device, asService,
servicesFrom, asCustomdForm,
registrationStatus, bonobService,
Service,
} from "../src/sonos"; } from "../src/sonos";
import { aSonosDevice, aService } from "./builders";
const mockSonosManagerConstructor = <jest.Mock<SonosManager>>SonosManager; const mockSonosManagerConstructor = <jest.Mock<SonosManager>>SonosManager;
describe("sonos", () => { describe("sonos", () => {
@@ -21,40 +30,54 @@ describe("sonos", () => {
mockSonosManagerConstructor.mockClear(); mockSonosManagerConstructor.mockClear();
}); });
describe("bonobRegistrationStatus", () => { describe("asService", () => {
describe("when bonob is registered", () => { it("should convert", () => {
it("should return 'registered'", () => { const musicService: MusicService = {
const bonob = { Name: "Amazon Music",
name: "some bonob", Version: "1.1",
id: 123, Uri: "https://sonos.amazonmusic.com/",
}; SecureUri: "https://sonos.amazonmusic.com/",
expect( ContainerType: "MService",
registrationStatus( Capabilities: "2208321",
[ Presentation: {
{ id: 1, name: "not bonob" }, Strings: {
bonob, Version: "23",
{ id: 2, name: "also not bonob" }, Uri: "https://sonos.amazonmusic.com/strings.xml",
], },
bonob PresentationMap: {
) Version: "17",
).toBe("registered"); Uri: "https://sonos.amazonmusic.com/PresentationMap.xml",
}); },
}); },
Id: 201,
Policy: { Auth: "DeviceLink", PollInterval: 60 },
Manifest: {
Uri: "",
Version: "",
},
};
describe("when bonob is not registered", () => { expect(asService(musicService)).toEqual({
it("should return not-registered", () => { name: "Amazon Music",
expect( sid: 201,
registrationStatus([{ id: 1, name: "not bonob" }], { uri: "https://sonos.amazonmusic.com/",
name: "bonob", secureUri: "https://sonos.amazonmusic.com/",
id: 999, strings: {
}) uri: "https://sonos.amazonmusic.com/strings.xml",
).toBe("not-registered"); version: "23",
},
presentation: {
uri: "https://sonos.amazonmusic.com/PresentationMap.xml",
version: "17",
},
pollInterval: 60,
authType: "DeviceLink",
}); });
}); });
}); });
describe("asDevice", () => { describe("asDevice", () => {
it("should convert", async () => { it("should convert", () => {
const musicServicesService = { const musicServicesService = {
ListAndParseAvailableServices: jest.fn(), ListAndParseAvailableServices: jest.fn(),
}; };
@@ -71,57 +94,119 @@ describe("sonos", () => {
APPLE_MUSIC, APPLE_MUSIC,
]); ]);
expect(await asDevice(device)).toEqual({ expect(asDevice(device)).toEqual({
name: "d1", name: "d1",
group: "g1", group: "g1",
ip: "127.0.0.222", ip: "127.0.0.222",
port: 123, 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 { describe("bonobService", () => {
const device = { describe("when the bonob root does not have a trailing /", () => {
name: "device123", it("should return a valid bonob service", () => {
group: "", expect(
ip: "127.0.0.11", bonobService("some-bonob", 876, "http://bonob.example.com")
port: 123, ).toEqual({
services: [], name: "some-bonob",
}; sid: 876,
return { ...device, ...params }; uri: `http://bonob.example.com/ws/sonos`,
} secureUri: `http://bonob.example.com/ws/sonos`,
strings: {
describe("servicesFrom", () => { uri: `http://bonob.example.com/sonos/strings.xml`,
it("should only return uniq services, sorted by name", () => { version: "1",
const service1 = { id: 1, name: "D" }; },
const service2 = { id: 2, name: "B" }; presentation: {
const service3 = { id: 3, name: "C" }; uri: `http://bonob.example.com/sonos/presentationMap.xml`,
const service4 = { id: 4, name: "A" }; version: "1",
},
const d1 = someDevice({ services: shuffle([service1, service2]) }); pollInterval: 1200,
const d2 = someDevice({ authType: "Anonymous",
services: shuffle([service1, service2, service3]), });
}); });
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([ describe("asCustomdForm", () => {
service4, describe("when all values specified", () => {
service2, it("should return a form", () => {
service3, const csrfToken = uuid();
service1, 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(disabled).toEqual(SONOS_DISABLED);
expect(await disabled.devices()).toEqual([]); expect(await disabled.devices()).toEqual([]);
expect(await disabled.services()).toEqual([]);
expect(await disabled.register(aService())).toEqual(false);
}); });
}); });
describe("sonos device discovery", () => { describe("sonos device discovery", () => {
const device1_MusicServicesService = {
ListAndParseAvailableServices: jest.fn(),
};
const device1 = { const device1 = {
Name: "device1", Name: "device1",
GroupName: "group1", GroupName: "group1",
Host: "127.0.0.11", Host: "127.0.0.11",
Port: 111, Port: 111,
MusicServicesService: (device1_MusicServicesService as unknown) as MusicServicesService,
} as SonosDevice; } as SonosDevice;
const device2_MusicServicesService = {
ListAndParseAvailableServices: jest.fn(),
};
const device2 = { const device2 = {
Name: "device2", Name: "device2",
GroupName: "group2", GroupName: "group2",
Host: "127.0.0.22", Host: "127.0.0.22",
Port: 222, Port: 222,
MusicServicesService: (device2_MusicServicesService as unknown) as MusicServicesService,
} as SonosDevice; } as SonosDevice;
beforeEach(() => {
device1_MusicServicesService.ListAndParseAvailableServices.mockClear();
device2_MusicServicesService.ListAndParseAvailableServices.mockClear();
});
describe("when no sonos seed host is provided", () => { describe("when no sonos seed host is provided", () => {
it("should perform auto-discovery", async () => { it("should perform auto-discovery", async () => {
const sonosManager = { const sonosManager = {
@@ -241,99 +315,323 @@ describe("sonos", () => {
); );
sonosManager.InitializeWithDiscovery.mockResolvedValue(true); sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
device1_MusicServicesService.ListAndParseAvailableServices.mockResolvedValue(
[AMAZON_MUSIC, APPLE_MUSIC]
);
device2_MusicServicesService.ListAndParseAvailableServices.mockResolvedValue(
[AUDIBLE]
);
const actualDevices = await sonos(undefined).devices(); const actualDevices = await sonos(undefined).devices();
expect(
device1_MusicServicesService.ListAndParseAvailableServices
).toHaveBeenCalled();
expect(
device2_MusicServicesService.ListAndParseAvailableServices
).toHaveBeenCalled();
expect(actualDevices).toEqual([ expect(actualDevices).toEqual([
{ {
name: device1.Name, name: device1.Name,
group: device1.GroupName, group: device1.GroupName,
ip: device1.Host, ip: device1.Host,
port: device1.Port, port: device1.Port,
services: [
{
name: AMAZON_MUSIC.Name,
id: AMAZON_MUSIC.Id,
},
{
name: APPLE_MUSIC.Name,
id: APPLE_MUSIC.Id,
},
],
}, },
{ {
name: device2.Name, name: device2.Name,
group: device2.GroupName, group: device2.GroupName,
ip: device2.Host, ip: device2.Host,
port: device2.Port, port: device2.Port,
services: [
{
name: AUDIBLE.Name,
id: AUDIBLE.Id,
},
],
}, },
]); ]);
}); });
}); });
describe("when initialisation returns false", () => { describe("when SonosManager initialisation returns false", () => {
it("should return empty []", async () => { it("should return no devices", async () => {
const initialize = jest.fn();
const sonosManager = { const sonosManager = {
InitializeWithDiscovery: initialize as ( InitializeWithDiscovery: jest.fn(),
x: number
) => Promise<boolean>,
Devices: [device1, device2], Devices: [device1, device2],
} as SonosManager; };
mockSonosManagerConstructor.mockReturnValue(sonosManager); mockSonosManagerConstructor.mockReturnValue(
initialize.mockResolvedValue(false); (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(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", () => { describe("when getting devices fails", () => {
it("should return empty []", async () => { it("should return no devices", async () => {
const initialize = jest.fn(); const sonosManager = {
InitializeWithDiscovery: jest.fn(),
const sonosManager = ({
InitializeWithDiscovery: initialize as (
x: number
) => Promise<boolean>,
Devices: () => { Devices: () => {
throw Error("Boom"); throw Error("Boom");
}, },
} as unknown) as SonosManager; };
mockSonosManagerConstructor.mockReturnValue(sonosManager); mockSonosManagerConstructor.mockReturnValue(
initialize.mockResolvedValue(true); (sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const actualDevices = await sonos("").devices(); const services = await sonos().services();
expect(SonosManager).toHaveBeenCalledTimes(1); 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"> <div id="content">
<h1>bonob</h1> <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> <h2>Devices</h2>
<ul> <ul>
<% it.devices.forEach(function(d){ %> <% it.devices.forEach(function(d){ %>
@@ -12,7 +21,7 @@
<h2>Services <%= it.services.length %></h2> <h2>Services <%= it.services.length %></h2>
<ul> <ul>
<% it.services.forEach(function(s){ %> <% it.services.forEach(function(s){ %>
<li><%= s.name %> (<%= s.id %>)</li> <li><%= s.name %> (<%= s.sid %>)</li>
<% }) %> <% }) %>
</ul> </ul>
</div> </div>

View File

@@ -691,6 +691,11 @@
resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.10.24.tgz#dede004deed3b3f99c4db0bdb9ee21cae25befdd" resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.10.24.tgz#dede004deed3b3f99c4db0bdb9ee21cae25befdd"
integrity sha512-T3NQD8hXNW2sRsSbLNjF/aBo18MyJlbw0lSpQHB/eZZtScPdexN4HSa8cByYwTw9Wy7KuOFr81mlDQcQQaZ79w== 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@*": "@types/yargs-parser@*":
version "20.2.0" version "20.2.0"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.0.tgz#dd3e6699ba3237f0348cd085e4698780204842f9" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.0.tgz#dd3e6699ba3237f0348cd085e4698780204842f9"
@@ -2165,6 +2170,11 @@ has@^1.0.3:
dependencies: dependencies:
function-bind "^1.1.1" 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: hosted-git-info@^2.1.4:
version "2.8.8" version "2.8.8"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" 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: dependencies:
p-locate "^4.1.0" 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: lodash.sortby@^4.7.0:
version "4.7.0" version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= 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" version "4.17.20"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== 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" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== 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: node-int64@^0.4.0:
version "0.4.0" version "0.4.0"
resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" 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== integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
ts-jest@^26.4.4: ts-jest@^26.4.4:
version "26.4.4" version "26.5.0"
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.4.4.tgz#61f13fb21ab400853c532270e52cc0ed7e502c49" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.5.0.tgz#3e3417d91bc40178a6716d7dacc5b0505835aa21"
integrity sha512-3lFWKbLxJm34QxyVNNCgXX1u4o/RV0myvA2y2Bxm46iGIjKlaY0own9gIckbjZJPn+WaJEnfPPJ20HHGpoq4yg== integrity sha512-Ya4IQgvIFNa2Mgq52KaO8yBw2W8tWp61Ecl66VjF0f5JaV8u50nGoptHVILOPGoI7SDnShmEqnYQEmyHdQ+56g==
dependencies: dependencies:
"@types/jest" "26.x" "@types/jest" "26.x"
bs-logger "0.x" bs-logger "0.x"
@@ -4523,7 +4535,7 @@ ts-jest@^26.4.4:
fast-json-stable-stringify "2.x" fast-json-stable-stringify "2.x"
jest-util "^26.1.0" jest-util "^26.1.0"
json5 "2.x" json5 "2.x"
lodash.memoize "4.x" lodash "4.x"
make-error "1.x" make-error "1.x"
mkdirp "1.x" mkdirp "1.x"
semver "7.x" semver "7.x"
@@ -4712,7 +4724,7 @@ uuid@^3.3.2:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
uuid@^8.3.0: uuid@^8.3.0, uuid@^8.3.2:
version "8.3.2" version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==