From ab432fa8ce666686cb03565640597aa49c32b7ef Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 29 Jan 2021 17:25:19 +1100 Subject: [PATCH] Listing devices and services on bonob page sourced from sonos devices --- .gitignore | 1 + Dockerfile | 2 +- README.md | 24 +++- jest.config.js | 1 + package.json | 2 + src/app.ts | 2 +- src/server.ts | 11 +- src/sonos.ts | 75 ++++++---- tests/index.test.ts | 76 ++++++++--- tests/music_services.ts | 76 +++++++++++ tests/setup.js | 9 ++ tests/sonos.test.ts | 293 ++++++++++++++++++++++++++++++++++++++++ web/views/devices.eta | 6 - web/views/index.eta | 8 +- yarn.lock | 10 ++ 15 files changed, 534 insertions(+), 62 deletions(-) create mode 100644 tests/music_services.ts create mode 100644 tests/setup.js create mode 100644 tests/sonos.test.ts delete mode 100644 web/views/devices.eta diff --git a/.gitignore b/.gitignore index e8b2497..e53fea7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .nyc_output .vscode build +ignore node_modules diff --git a/Dockerfile b/Dockerfile index 7be2d4f..862f203 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN yarn install && \ FROM node:14.15-alpine -EXPOSE 3000 +EXPOSE 4534 WORKDIR /bonob diff --git a/README.md b/README.md index 36a4808..c1deeb9 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,31 @@ bonob is ditributed via docker and can be run in a number of ways ### Full sonos device auto-discovery by using docker --network host ``` docker run \ - -e PORT=3000 \ - -p 3000 \ + -p 4534 \ --network host \ simojenki/bonob ``` -### Full sonos device auto-discovery by using a sonos seed device, without requiring docker host networking +### Full sonos device auto-discovery on custom port by using a sonos seed device, without requiring docker host networking ``` docker run \ - -e PORT=3000 \ -e BONOB_SONOS_SEED_HOST=192.168.1.123 \ + -e PORT=3000 \ -p 3000 \ simojenki/bonob -``` \ No newline at end of file +``` + +### Disabling sonos device discovery entirely +``` +docker run \ + -e BONOB_SONOS_SEED_HOST=disabled \ + -p 4534 \ + simojenki/bonob +``` + +## Configuration + +item | default value | description +---- | ------------- | ----------- +PORT | 4534 | Default http port for bonob to listen on +BONOB_SONOS_SEED_HOST | undefined | sonos device seed host for auto-discovery, or 'disabled' to turn off device discovery entirely diff --git a/jest.config.js b/jest.config.js index 91a2d2c..dbbc977 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,5 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', + setupFilesAfterEnv: ["/tests/setup.js"], }; \ No newline at end of file diff --git a/package.json b/package.json index c714eef..60713f3 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,13 @@ "@svrooij/sonos": "^2.3.0", "@types/express": "^4.17.11", "@types/node": "^14.14.22", + "@types/underscore": "1.10.24", "axios": "^0.21.1", "eta": "^1.12.1", "express": "^4.17.1", "ts-md5": "^1.2.7", "typescript": "^4.1.3", + "underscore":"^1.12.0", "winston": "^3.3.3" }, "devDependencies": { diff --git a/src/app.ts b/src/app.ts index beedd9d..5280130 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,7 +1,7 @@ import sonos from "./sonos"; import server from "./server"; -const PORT = process.env["PORT"] || 3000; +const PORT = process.env["PORT"] || 4534; const app = server(sonos(process.env["BONOB_SONOS_SEED_HOST"])); diff --git a/src/server.ts b/src/server.ts index 0cbb50e..c83609d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ import express, { Express } from "express"; import * as Eta from "eta"; -import { Sonos } from "./sonos"; +import { Sonos, servicesFrom } from "./sonos"; function server(sonos: Sonos): Express { const app = express(); @@ -11,9 +11,12 @@ function server(sonos: Sonos): Express { app.set("views", "./web/views"); app.get("/", (_, res) => { - res.render("index", { - devices: sonos.devices(), - }); + sonos.devices().then(devices => { + res.render("index", { + devices, + services: servicesFrom(devices), + }) + }) }); return app; diff --git a/src/sonos.ts b/src/sonos.ts index 45962d9..757d729 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -1,27 +1,49 @@ import { SonosManager, SonosDevice } from "@svrooij/sonos"; +// import { MusicService } from "@svrooij/sonos/lib/services"; +import { uniq } from "underscore"; import logger from "./logger"; -type Device = { +export type Device = { name: string; group: string; ip: string; port: number; + services: Service[]; +}; + +export type Service = { + name: string; + id: number; }; export interface Sonos { - devices: () => Device[]; + devices: () => Promise; } export const SONOS_DISABLED: Sonos = { - devices: () => [], + devices: () => Promise.resolve([]), }; -const asDevice = (sonosDevice: SonosDevice) => ({ - name: sonosDevice.Name, - group: sonosDevice.GroupName || "", - ip: sonosDevice.Host, - port: sonosDevice.Port, -}); +export const servicesFrom = (devices: Device[]) => + uniq( + devices.flatMap((d) => d.services), + false, + (s) => s.id + ); + +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, + })), + }) + ); const setupDiscovery = ( manager: SonosManager, @@ -37,24 +59,24 @@ const setupDiscovery = ( }; export function autoDiscoverySonos(sonosSeedHost?: string): Sonos { - const manager = new SonosManager(); - - setupDiscovery(manager, sonosSeedHost) - .then((r) => { - if (r) logger.info({ devices: manager.Devices.map(asDevice) }); - else logger.warn("Failed to auto discover hosts!"); - }) - .catch((e) => { - logger.warn(`Failed to find sonos devices ${e}`); - }); - return { - devices: () => { - try { - return manager.Devices.map(asDevice) - }catch(e) { - 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}`); + return []; + }); }, }; } @@ -62,6 +84,7 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos { export default function sonos(sonosSeedHost?: string): Sonos { switch (sonosSeedHost) { case "disabled": + logger.info("Sonos device discovery disabled"); return SONOS_DISABLED; default: return autoDiscoverySonos(sonosSeedHost); diff --git a/tests/index.test.ts b/tests/index.test.ts index 85df750..4e1bea9 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,6 +1,6 @@ import request from "supertest"; import makeServer from "../src/server"; -import { SONOS_DISABLED, Sonos } from "../src/sonos"; +import { SONOS_DISABLED, Sonos, Device } from "../src/sonos"; describe("index", () => { describe("when sonos integration is disabled", () => { @@ -9,37 +9,77 @@ describe("index", () => { describe("devices list", () => { it("should be empty", async () => { const res = await request(server).get("/").send(); - + expect(res.status).toEqual(200); - expect(res.text).not.toMatch(/class=device/) + expect(res.text).not.toMatch(/class=device/); }); }); }); + const device1 : Device = { + name: "device1", + group: "group1", + ip: "172.0.0.1", + port: 4301, + services: [ + { + name: "s1", + id: 1, + }, + { + name: "s2", + id: 2, + }, + ], + }; + + const device2: Device = { + name: "device2", + group: "group2", + ip: "172.0.0.2", + port: 4302, + services: [ + { + name: "s3", + id: 3, + }, + { + name: "s4", + id: 4, + }, + ], + } + + describe("when sonos integration is enabled", () => { const fakeSonos: Sonos = { - devices: () => [{ - name: "device1", - group: "group1", - ip: "172.0.0.1", - port: 4301 - },{ - name: "device2", - group: "group2", - ip: "172.0.0.2", - port: 4302 - }] - } + devices: () =>Promise.resolve([device1, device2]), + }; const server = makeServer(fakeSonos); 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\)/ + ); + }); + + 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\)/); + expect(res.text).toMatch(/s2\s+\(2\)/); + expect(res.text).toMatch(/s3\s+\(3\)/); + expect(res.text).toMatch(/s4\s+\(4\)/); }); }); }); diff --git a/tests/music_services.ts b/tests/music_services.ts new file mode 100644 index 0000000..86a124a --- /dev/null +++ b/tests/music_services.ts @@ -0,0 +1,76 @@ +import { MusicService } from "@svrooij/sonos/lib/services"; + +export const AMAZON_MUSIC: 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: "", + }, +}; + +export const APPLE_MUSIC: MusicService = { + Name: "Apple Music", + Version: "1.1", + Uri: "https://sonos-music.apple.com/ws/SonosSoap", + SecureUri: "https://sonos-music.apple.com/ws/SonosSoap", + ContainerType: "MService", + Capabilities: "3117633", + Presentation: { + Strings: { + Version: "24", + Uri: "https://sonos-music.apple.com/xml/strings.xml", + }, + PresentationMap: { + Version: "22", + Uri: "http://sonos-pmap.ws.sonos.com/applemusicbrand_pmap3.xml", + }, + }, + Id: 204, + Policy: { Auth: "AppLink", PollInterval: 60 }, + Manifest: { + Uri: "", + Version: "", + }, +}; + +export const AUDIBLE: MusicService = { + Name: "Audible", + Version: "1.1", + Uri: "https://sonos.audible.com/smapi", + SecureUri: "https://sonos.audible.com/smapi", + ContainerType: "MService", + Capabilities: "1095249", + Presentation: { + Strings: { + Version: "5", + Uri: "https://sonos.audible.com/smapi/strings.xml", + }, + PresentationMap: { + Version: "5", + Uri: "https://sonos.audible.com/smapi/PresentationMap.xml", + }, + }, + Id: 239, + Policy: { Auth: "AppLink", PollInterval: 30 }, + Manifest: { + Uri: "", + Version: "", + }, +}; diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..4c1678f --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,9 @@ +global.console = { + log: jest.fn(), // console.log are ignored in tests + + // Keep native behaviour for other methods, use those to print out things in your own tests, not `console.log` + error: console.error, + warn: console.warn, + info: console.info, + debug: console.debug, +}; diff --git a/tests/sonos.test.ts b/tests/sonos.test.ts new file mode 100644 index 0000000..116e59e --- /dev/null +++ b/tests/sonos.test.ts @@ -0,0 +1,293 @@ +import { SonosManager, SonosDevice } from "@svrooij/sonos"; +import { MusicServicesService } from "@svrooij/sonos/lib/services"; +jest.mock("@svrooij/sonos"); + +import { AMAZON_MUSIC, APPLE_MUSIC, AUDIBLE } from "./music_services"; + +import sonos, { SONOS_DISABLED, asDevice, Device, servicesFrom } from "../src/sonos"; + +const mockSonosManagerConstructor = >SonosManager; + +describe("sonos", () => { + beforeEach(() => { + mockSonosManagerConstructor.mockClear(); + }); + + describe("asDevice", () => { + it("should convert", async () => { + const musicServicesService = { + ListAndParseAvailableServices: jest.fn(), + }; + const device = { + Name: "d1", + GroupName: "g1", + Host: "127.0.0.222", + Port: 123, + MusicServicesService: (musicServicesService as unknown) as MusicServicesService, + } as SonosDevice; + + musicServicesService.ListAndParseAvailableServices.mockResolvedValue([ + AMAZON_MUSIC, + APPLE_MUSIC, + ]); + + expect(await 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", () => { + const service1 = { id: 1, name: "service1" }; + const service2 = { id: 2, name: "service2" }; + const service3 = { id: 3, name: "service3" }; + const service4 = { id: 4, name: "service4" }; + + const d1 = someDevice({ services: [service1, service2] }); + const d2 = someDevice({ services: [service1, service2, service3] }); + const d3 = someDevice({ services: [service4] }); + + const devices: Device[] = [d1, d2, d3]; + + expect(servicesFrom(devices)).toEqual([service1, service2, service3, service4]) + }); + }); + + describe("when is disabled", () => { + it("should return a disabled client", async () => { + const disabled = sonos("disabled"); + + expect(disabled).toEqual(SONOS_DISABLED); + expect(await disabled.devices()).toEqual([]); + }); + }); + + 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 = { + InitializeWithDiscovery: jest.fn(), + Devices: [], + }; + + mockSonosManagerConstructor.mockReturnValue( + (sonosManager as unknown) as SonosManager + ); + sonosManager.InitializeWithDiscovery.mockResolvedValue(true); + + const actualDevices = await sonos(undefined).devices(); + + expect(SonosManager).toHaveBeenCalledTimes(1); + expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10); + + expect(actualDevices).toEqual([]); + }); + }); + + describe("when sonos seed host is empty string", () => { + it("should perform auto-discovery", async () => { + const sonosManager = { + InitializeWithDiscovery: jest.fn(), + Devices: [], + }; + + mockSonosManagerConstructor.mockReturnValue( + (sonosManager as unknown) as SonosManager + ); + sonosManager.InitializeWithDiscovery.mockResolvedValue(true); + + const actualDevices = await sonos("").devices(); + + expect(SonosManager).toHaveBeenCalledTimes(1); + expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10); + + expect(actualDevices).toEqual([]); + }); + }); + + describe("when a sonos seed host is provided", () => { + it("should perform auto-discovery", async () => { + const seedHost = "theSeedsOfLife"; + + const sonosManager = { + InitializeFromDevice: jest.fn(), + Devices: [], + }; + + mockSonosManagerConstructor.mockReturnValue( + (sonosManager as unknown) as SonosManager + ); + sonosManager.InitializeFromDevice.mockResolvedValue(true); + + const actualDevices = await sonos(seedHost).devices(); + + expect(SonosManager).toHaveBeenCalledTimes(1); + expect(sonosManager.InitializeFromDevice).toHaveBeenCalledWith( + seedHost + ); + + expect(actualDevices).toEqual([]); + }); + }); + + describe("when some devices are found", () => { + it("should be able to return them", 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] + ); + 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(); + const sonosManager = { + InitializeWithDiscovery: initialize as ( + x: number + ) => Promise, + Devices: [device1, device2], + } as SonosManager; + + mockSonosManagerConstructor.mockReturnValue(sonosManager); + initialize.mockResolvedValue(false); + + const actualDevices = await sonos("").devices(); + + expect(SonosManager).toHaveBeenCalledTimes(1); + expect(initialize).toHaveBeenCalledWith(10); + + expect(actualDevices).toEqual([]); + }); + }); + + describe("when getting devices fails", () => { + it("should return empty []", async () => { + const initialize = jest.fn(); + + const sonosManager = ({ + InitializeWithDiscovery: initialize as ( + x: number + ) => Promise, + Devices: () => { + throw Error("Boom"); + }, + } as unknown) as SonosManager; + + mockSonosManagerConstructor.mockReturnValue(sonosManager); + initialize.mockResolvedValue(true); + + const actualDevices = await sonos("").devices(); + + expect(SonosManager).toHaveBeenCalledTimes(1); + expect(initialize).toHaveBeenCalledWith(10); + + expect(actualDevices).toEqual([]); + }); + }); + }); +}); diff --git a/web/views/devices.eta b/web/views/devices.eta deleted file mode 100644 index 366a205..0000000 --- a/web/views/devices.eta +++ /dev/null @@ -1,6 +0,0 @@ -
    -<% it.devices.forEach(function(d){ %> -
  • <%= d.name %> (<%= d.ip %>:<%= d.port %>)
  • -<% }) %> -
- diff --git a/web/views/index.eta b/web/views/index.eta index 8ab9d7d..b5916b2 100644 --- a/web/views/index.eta +++ b/web/views/index.eta @@ -5,7 +5,13 @@

Devices

    <% it.devices.forEach(function(d){ %> -
  • <%= d.name %> (<%= d.ip %>:<%= d.port %>)
  • +
  • <%= d.name %> (<%= d.ip %>:<%= d.port %>)
  • + <% }) %> +
+

Services <%= it.services.length %>

+
    + <% it.services.forEach(function(s){ %> +
  • <%= s.name %> (<%= s.id %>)
  • <% }) %>
\ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 1773cd7..0c8c278 100644 --- a/yarn.lock +++ b/yarn.lock @@ -686,6 +686,11 @@ dependencies: "@types/superagent" "*" +"@types/underscore@1.10.24": + version "1.10.24" + resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.10.24.tgz#dede004deed3b3f99c4db0bdb9ee21cae25befdd" + integrity sha512-T3NQD8hXNW2sRsSbLNjF/aBo18MyJlbw0lSpQHB/eZZtScPdexN4HSa8cByYwTw9Wy7KuOFr81mlDQcQQaZ79w== + "@types/yargs-parser@*": version "20.2.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.0.tgz#dd3e6699ba3237f0348cd085e4698780204842f9" @@ -4614,6 +4619,11 @@ undefsafe@^2.0.3: dependencies: debug "^2.2.0" +underscore@^1.12.0: + version "1.12.0" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.12.0.tgz#4814940551fc80587cef7840d1ebb0f16453be97" + integrity sha512-21rQzss/XPMjolTiIezSu3JAjgagXKROtNrYFEOWK109qY1Uv2tVjPTZ1ci2HgvQDA16gHYSthQIJfB+XId/rQ== + union-value@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"