Listing devices and services on bonob page sourced from sonos devices

This commit is contained in:
simojenki
2021-01-29 17:25:19 +11:00
parent faf9c343a2
commit ab432fa8ce
15 changed files with 534 additions and 62 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.nyc_output .nyc_output
.vscode .vscode
build build
ignore
node_modules node_modules

View File

@@ -14,7 +14,7 @@ RUN yarn install && \
FROM node:14.15-alpine FROM node:14.15-alpine
EXPOSE 3000 EXPOSE 4534
WORKDIR /bonob WORKDIR /bonob

View File

@@ -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 ### Full sonos device auto-discovery by using docker --network host
``` ```
docker run \ docker run \
-e PORT=3000 \ -p 4534 \
-p 3000 \
--network host \ --network host \
simojenki/bonob 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 \ docker run \
-e PORT=3000 \
-e BONOB_SONOS_SEED_HOST=192.168.1.123 \ -e BONOB_SONOS_SEED_HOST=192.168.1.123 \
-e PORT=3000 \
-p 3000 \ -p 3000 \
simojenki/bonob simojenki/bonob
``` ```
### 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

View File

@@ -1,4 +1,5 @@
module.exports = { module.exports = {
preset: 'ts-jest', preset: 'ts-jest',
testEnvironment: 'node', testEnvironment: 'node',
setupFilesAfterEnv: ["<rootDir>/tests/setup.js"],
}; };

View File

@@ -9,11 +9,13 @@
"@svrooij/sonos": "^2.3.0", "@svrooij/sonos": "^2.3.0",
"@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",
"axios": "^0.21.1", "axios": "^0.21.1",
"eta": "^1.12.1", "eta": "^1.12.1",
"express": "^4.17.1", "express": "^4.17.1",
"ts-md5": "^1.2.7", "ts-md5": "^1.2.7",
"typescript": "^4.1.3", "typescript": "^4.1.3",
"underscore":"^1.12.0",
"winston": "^3.3.3" "winston": "^3.3.3"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,7 +1,7 @@
import sonos from "./sonos"; import sonos from "./sonos";
import server from "./server"; 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"])); const app = server(sonos(process.env["BONOB_SONOS_SEED_HOST"]));

View File

@@ -1,6 +1,6 @@
import express, { Express } from "express"; import express, { Express } from "express";
import * as Eta from "eta"; import * as Eta from "eta";
import { Sonos } from "./sonos"; import { Sonos, servicesFrom } from "./sonos";
function server(sonos: Sonos): Express { function server(sonos: Sonos): Express {
const app = express(); const app = express();
@@ -11,9 +11,12 @@ function server(sonos: Sonos): Express {
app.set("views", "./web/views"); app.set("views", "./web/views");
app.get("/", (_, res) => { app.get("/", (_, res) => {
res.render("index", { sonos.devices().then(devices => {
devices: sonos.devices(), res.render("index", {
}); devices,
services: servicesFrom(devices),
})
})
}); });
return app; return app;

View File

@@ -1,27 +1,49 @@
import { SonosManager, SonosDevice } from "@svrooij/sonos"; import { SonosManager, SonosDevice } from "@svrooij/sonos";
// import { MusicService } from "@svrooij/sonos/lib/services";
import { uniq } from "underscore";
import logger from "./logger"; import logger from "./logger";
type Device = { export type Device = {
name: string; name: string;
group: string; group: string;
ip: string; ip: string;
port: number; port: number;
services: Service[];
};
export type Service = {
name: string;
id: number;
}; };
export interface Sonos { export interface Sonos {
devices: () => Device[]; devices: () => Promise<Device[]>;
} }
export const SONOS_DISABLED: Sonos = { export const SONOS_DISABLED: Sonos = {
devices: () => [], devices: () => Promise.resolve([]),
}; };
const asDevice = (sonosDevice: SonosDevice) => ({ export const servicesFrom = (devices: Device[]) =>
name: sonosDevice.Name, uniq(
group: sonosDevice.GroupName || "", devices.flatMap((d) => d.services),
ip: sonosDevice.Host, false,
port: sonosDevice.Port, (s) => s.id
}); );
export const asDevice = (sonosDevice: SonosDevice): Promise<Device> =>
sonosDevice.MusicServicesService.ListAndParseAvailableServices().then(
(services) => ({
name: sonosDevice.Name,
group: sonosDevice.GroupName || "",
ip: sonosDevice.Host,
port: sonosDevice.Port,
services: services.map((s) => ({
name: s.Name,
id: s.Id,
})),
})
);
const setupDiscovery = ( const setupDiscovery = (
manager: SonosManager, manager: SonosManager,
@@ -37,24 +59,24 @@ const setupDiscovery = (
}; };
export function autoDiscoverySonos(sonosSeedHost?: string): Sonos { 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 { return {
devices: () => { devices: async () => {
try { const manager = new SonosManager();
return manager.Devices.map(asDevice) return setupDiscovery(manager, sonosSeedHost)
}catch(e) { .then((success) => {
return [] 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 { export default function sonos(sonosSeedHost?: string): Sonos {
switch (sonosSeedHost) { switch (sonosSeedHost) {
case "disabled": case "disabled":
logger.info("Sonos device discovery disabled");
return SONOS_DISABLED; return SONOS_DISABLED;
default: default:
return autoDiscoverySonos(sonosSeedHost); return autoDiscoverySonos(sonosSeedHost);

View File

@@ -1,6 +1,6 @@
import request from "supertest"; import request from "supertest";
import makeServer from "../src/server"; import makeServer from "../src/server";
import { SONOS_DISABLED, Sonos } from "../src/sonos"; import { SONOS_DISABLED, Sonos, Device } from "../src/sonos";
describe("index", () => { describe("index", () => {
describe("when sonos integration is disabled", () => { describe("when sonos integration is disabled", () => {
@@ -11,25 +11,50 @@ describe("index", () => {
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).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", () => { describe("when sonos integration is enabled", () => {
const fakeSonos: Sonos = { const fakeSonos: Sonos = {
devices: () => [{ devices: () =>Promise.resolve([device1, device2]),
name: "device1", };
group: "group1",
ip: "172.0.0.1",
port: 4301
},{
name: "device2",
group: "group2",
ip: "172.0.0.2",
port: 4302
}]
}
const server = makeServer(fakeSonos); const server = makeServer(fakeSonos);
@@ -38,8 +63,23 @@ describe("index", () => {
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(/device1\s+\(172.0.0.1:4301\)/) expect(res.text).toMatch(
expect(res.text).toMatch(/device2\s+\(172.0.0.2:4302\)/) /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\)/);
}); });
}); });
}); });

76
tests/music_services.ts Normal file
View File

@@ -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: "",
},
};

9
tests/setup.js Normal file
View File

@@ -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,
};

293
tests/sonos.test.ts Normal file
View File

@@ -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 = <jest.Mock<SonosManager>>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> = {}): 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<boolean>,
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<boolean>,
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([]);
});
});
});
});

View File

@@ -1,6 +0,0 @@
<ul>
<% it.devices.forEach(function(d){ %>
<li class="device"><%= d.name %> (<%= d.ip %>:<%= d.port %>)</li>
<% }) %>
</ul>

View File

@@ -5,7 +5,13 @@
<h2>Devices</h2> <h2>Devices</h2>
<ul> <ul>
<% it.devices.forEach(function(d){ %> <% it.devices.forEach(function(d){ %>
<li><%= d.name %> (<%= d.ip %>:<%= d.port %>)</li> <li><%= d.name %> (<%= d.ip %>:<%= d.port %>)</li>
<% }) %>
</ul>
<h2>Services <%= it.services.length %></h2>
<ul>
<% it.services.forEach(function(s){ %>
<li><%= s.name %> (<%= s.id %>)</li>
<% }) %> <% }) %>
</ul> </ul>
</div> </div>

View File

@@ -686,6 +686,11 @@
dependencies: dependencies:
"@types/superagent" "*" "@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@*": "@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"
@@ -4614,6 +4619,11 @@ undefsafe@^2.0.3:
dependencies: dependencies:
debug "^2.2.0" 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: union-value@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"