From 2ed2fce28095d3c0ca6296ab27256aa7d5ccfdbb Mon Sep 17 00:00:00 2001 From: simojenki Date: Sun, 31 Jan 2021 19:02:03 +1100 Subject: [PATCH] Ability to register bonob service with sonos via button --- README.md | 3 +- package.json | 3 + src/app.ts | 15 +- src/server.ts | 24 +- src/sonos.ts | 191 ++++++++++----- tests/builders.ts | 48 ++++ tests/index.test.ts | 145 +++++------ tests/sonos.test.ts | 582 +++++++++++++++++++++++++++++++++----------- web/views/index.eta | 13 +- yarn.lock | 34 ++- 10 files changed, 742 insertions(+), 316 deletions(-) create mode 100644 tests/builders.ts diff --git a/README.md b/README.md index 4b96ce7..db1ab02 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index 60713f3..8c15e8e 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app.ts b/src/app.ts index 949fecd..b61aef6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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; diff --git a/src/server.ts b/src/server.ts index 60678f5..f46207c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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; diff --git a/src/sonos.ts b/src/sonos.ts index e4b484f..f839544 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -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; + services: () => Promise; + register: (service: Service) => Promise; } 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 => - 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 => { + 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"); diff --git a/tests/builders.ts b/tests/builders.ts new file mode 100644 index 0000000..a87b60e --- /dev/null +++ b/tests/builders.ts @@ -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 => ({ + 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 { + return { + name: `device-${uuid()}`, + group: `group-${uuid()}`, + ip: randomIpAddress(), + port: randomInt(10_000), + ...fields, + }; +} + +export function aSonosDevice( + fields: Partial = {} +): SonosDevice { + return { + Name: `device-${uuid()}`, + GroupName: `group-${uuid()}`, + Host: randomIpAddress(), + Port: randomInt(10_000), + ...fields, + } as SonosDevice; +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 335391f..a5b67ec 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -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,64 +19,61 @@ 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\)/); }); }); - + describe("services", () => { it("should contain a list of services returned from sonos", async () => { const res = await request(server).get("/").send(); - + expect(res.status).toEqual(200); expect(res.text).toMatch(/Services\s+4/); expect(res.text).toMatch(/s1\s+\(1\)/); @@ -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 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 service1 = aService(); + 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/); + }); }); }); }); diff --git a/tests/sonos.test.ts b/tests/sonos.test.ts index 044f0cd..2366007 100644 --- a/tests/sonos.test.ts +++ b/tests/sonos.test.ts @@ -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 = >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 { - 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, + 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, + 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: ``, + }); + 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: ``, + }); + + 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: ``, + }); + + 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: ``, + }); + mockPost.mockResolvedValue({ status: 500, data: "" }); + + const result = await sonos().register(serviceToAdd); + + expect(result).toEqual(false); }); }); }); diff --git a/web/views/index.eta b/web/views/index.eta index b8454e1..484fe90 100644 --- a/web/views/index.eta +++ b/web/views/index.eta @@ -2,7 +2,16 @@

bonob

-

<%= it.bonob.name %> (<%= it.bonob.id %>) is <%= it.registration %>

+

<%= it.bonobService.name %> (<%= it.bonobService.sid %>) +

Expected config

+
<%= JSON.stringify(it.bonobService) %>
+ <% if(it.registeredBonobService) { %> +

Existing service config

+
<%= JSON.stringify(it.registeredBonobService) %>
+ <% } else { %> +

No existing service registration

+ <% } %> +

Devices

    <% it.devices.forEach(function(d){ %> @@ -12,7 +21,7 @@

    Services <%= it.services.length %>

      <% it.services.forEach(function(s){ %> -
    • <%= s.name %> (<%= s.id %>)
    • +
    • <%= s.name %> (<%= s.sid %>)
    • <% }) %>
\ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 0c8c278..56010e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==