├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── __mocks__ └── nfccard-tool.js ├── babel.config.json ├── index.js ├── jest.config.js ├── lib ├── __tests__ │ ├── event_emitter.js │ ├── process_sonos_command.test.js │ └── sonos_nfc.test.js ├── process_sonos_command.js └── sonos_nfc.js ├── package-lock.json ├── package.json ├── setup_tests.js └── usersettings.json.example /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x, 16.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v2 25 | with: 26 | persist-credentials: false 27 | 28 | - name: Reconfigure git to use HTTP authentication 29 | run: > 30 | git config --global url."https://github.com/".insteadOf 31 | ssh://git@github.com/ 32 | 33 | - name: Install system dependencies 34 | run: sudo apt install libpcsclite1 libpcsclite-dev pcscd 35 | 36 | - name: Use Node.js ${{ matrix.node-version }} 37 | uses: actions/setup-node@v2 38 | with: 39 | node-version: ${{ matrix.node-version }} 40 | cache: 'npm' 41 | 42 | - run: npm ci 43 | - run: npm run build --if-present 44 | - run: npm test 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /coverage 3 | usersettings.json -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run format 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .husky 2 | .prettierrc 3 | __tests__ 4 | __mocks__ 5 | babel.config.json 6 | jest.config.js 7 | setup_tests.js -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /coverage -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ryan Olf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Node.js CI](https://github.com/ryanolf/node-sonos-nfc/actions/workflows/node.js.yml/badge.svg)](https://github.com/ryanolf/node-sonos-nfc/actions/workflows/node.js.yml) 2 | 3 | # Background 4 | 5 | I wanted a way for my pre-smartphone daughter to select and play music in our house. There are companies that build purpose-built players for kids that utilize cards to control what they play, but they're expensive and represent another _thing_ that we have to keep around. We already pay a monthly fee to stream pretty much anything -- why pay more? Why get another crappy speaker? 6 | 7 | Snooping around, I found [Sonos Vinyl Emulator](https://github.com/hankhank10/vinylemulator), which does exactly what I want. Put a pre-programmed card (or anything else you can stick a NFC NTAG sticker on) near a cheap NFC card reader, and voilà, your chosen music plays from Apple Music or Spotify. Cards can also be programmed with commands, like skip track, adjust volume, turn on shuffle, etc, and are portable between rooms (if you care to setup multiple Vinyl Emulators). I imagined that making the cards themselves could be a fun activity, and with NTAG213 stickers <$15 for 50, we could make a bunch. There is also a nice aesthetic to controlling your streaming service with physical media -- the impetus behind Vinyl Emulator in the first place. 8 | 9 | Sadly, newer versions of the recommended and widely available ACR122U card reader are [not currently compatible with NFCpy](https://github.com/nfcpy/nfcpy/issues/154), a library that Vinyl Emulator uses under the hood to talk to the reader. It turns out the ACR122U isn't really recommended for general NFC applications, since even though it has a chip inside that is capable of the full NFC suite of tricks, that chip is managed by an onboard controller that is not. The ACR122U is, however, a quite good smart card reader/writer via the PC/SC standard -- essentially a subset of NFC -- because that's what it was built for, and there are good PC/SC libraries available for most platforms and languages. 10 | 11 | Thus, I decided to build something compatible with Sonos Vinyl Emulator from the ground up using Node.js and the [nfc-pcsc](https://github.com/pokusew/nfc-pcsc) library, which _should_ support the wide range of card readers/writers that speak PC/SC, and _absolutely does_ support the ACR122U. Anyone using Vinyl Emulator can use this code for new controllers and their existing cards should work exactly the same. This might be useful, for example, if someone buys a second ACR122U and finds, as I did, that it doesn't work with NFCpy. 12 | 13 | Going forward, maybe I (and who knows, others?) can add additional features beyond reading the card and playing a song or executing another Sonos command. It might be useful, for example, to add a front-end for programming the cards easily without having to know the details of the Sonos API or how to find the album, track, or playlist code from your service of choice. 14 | 15 | For those parents out there with Sonos and kids, let me just say that this thing is a hit with kids at least as young 4. 16 | 17 | # Install 18 | 19 | ## You need a computer 20 | 21 | The basic setup here involves a PC/SC card reader attached to a computer on the same network as your Sonos. The computer could be any computer that runs Node.js (so, any computer) and has drivers available for your card reader (depends on the card reader). If you have the ACR122U, you can use pretty much any computer with USB and networking capability. A popular option if you don't want to hook up to an existing computer is to purchase a Raspberry Pi. I _think_ pretty much any model will do if it can properly power the card reader. I've used a version 3 and 4. There is a super cheap and tiny Pi Zero that could probably run the software but may struggle to source enough power for the card reader when it's actually reading cards. Check out [the Raspberry Pi documentation](https://www.raspberrypi.org/documentation/) if you want to setup a Raspberry Pi. 22 | 23 | ## Card reader setup 24 | 25 | This program uses the [nfc-pcsc] library to read (and someday?) write to PC/SC compatible smart card readers. The library is tested with the ACR122U but _should_ work with any PC/SC compatible reader. Instructions here are mainly focused on ACR122U because that's what has been tested. 26 | 27 | Make sure your card reader can be detected by your system by installing drivers as needed. For ACR122U on Ubuntu/Debian/Raspberry Pi OS: 28 | 29 | ``` 30 | $ sudo apt install libacsccid1 31 | ``` 32 | 33 | You also need to make sure your computer can speak PC/SC. In Ubuntu/Debian, install PC/SC libraries and daemon. You'll need to have a suitable build environment setup, e.g. have gcc installed. See the [node-pcsclite](https://github.com/pokusew/node-pcsclite) instructions if you have any issues. 34 | 35 | ``` 36 | $ sudo apt install libpcsclite1 libpcsclite-dev pcscd 37 | ``` 38 | 39 | If you're running a version of Linux, your computer may try to use the nfc kernel module to talk to tyour ACR122U. You don't want it to do this, so make sure the nfc and enabling modules are not loaded. In Ubuntu/Debian/Raspberry Pi OS, blacklist pn533, pn533_usb, nfc modules so that they don't hijack the card reader. 40 | 41 | ``` 42 | $ printf '%s\n' 'pn533' 'pn533_usb' 'nfc' | sudo tee /etc/modprobe.d/blacklist-nfc.conf 43 | ``` 44 | 45 | To make sure everything is square, it's probably a good idea to reboot. In Ubuntu/Debian/Raspberry Pi OS: 46 | 47 | ``` 48 | $ sudo reboot 49 | ``` 50 | 51 | ## Setup Node 52 | 53 | Install node and npm, e.g. download or follow the [official instructions](https://nodejs.org/en/download/), 54 | so that you can run this code. On Ubuntu/Debian/Raspberry Pi OS, I do this: 55 | 56 | ``` 57 | $ curl -sL https://deb.nodesource.com/setup_15.x | sudo -E bash - 58 | $ sudo apt-get install -y nodejs 59 | ``` 60 | 61 | ## Setup this code 62 | 63 | Install git and clone this repo. In Ubuntu/Debian/Raspberry Pi OS, 64 | 65 | ``` 66 | $ sudo apt install git 67 | $ git clone https://github.com/ryanolf/node-sonos-nfc.git 68 | ``` 69 | 70 | Install dependencies via `npm`. If you're following along in Ubuntu/Debian/Raspberry Pi OS, the commands are 71 | 72 | ``` 73 | $ cd node-sonos-nfc 74 | $ npm install 75 | ``` 76 | 77 | For simplicity, [sonos-http-api](https://github.com/jishi/node-sonos-http-api), needed for this program to work, is included as a dependency, though you don't need to use it if you already have an http api running elsewhere. 78 | 79 | If you _DO_ want to use the included Sonos HTTP API, you'll need to configure it. Rename the `usersettings.json.example` to `usersettings.json` and edit it to your liking. You'll need to set the `spotify` and/or `apple` sections to your credentials. You can also set the `http` section to your liking. The defaults should work fine for most people. 80 | 81 | ## Run all the time 82 | 83 | To run continuously and at boot, you'll want to run under some supervisor program. There are lots of options, like systemd (built-in already), supervisord, and pm2. I have found pm2, recommended by the author of Vinyl Emulator, to be very easy to use. To have pm2 spin-up sonos_nfc at boot and keep it 84 | running, install pm2 globally: 85 | 86 | ``` 87 | $ sudo npm install -g pm2 88 | ``` 89 | 90 | and spin-up sonos_nfc and sonos-http-api: 91 | 92 | ``` 93 | $ pm2 start npm -- run start-all 94 | ``` 95 | 96 | Then, to configure your system to run the startup, follow the instructions given when you run 97 | 98 | ``` 99 | $ pm2 startup 100 | ``` 101 | 102 | e.g. 103 | 104 | ``` 105 | $ sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u pi --hp /home/pi 106 | ``` 107 | 108 | If you already have the http API running elsewhere, you can direct this program to that server via the `usersettings.json` (rename it from .example and update to how you would like to use) and instead run just this program via `npm start`, so replace the `pm2 start` command above with 109 | 110 | ``` 111 | $ pm2 start npm -- start 112 | ``` 113 | 114 | ## Debug 115 | 116 | You can monitor the process output to see what's going on. If you're using pm2, you can see the process output via 117 | 118 | ``` 119 | $ pm2 log 120 | ``` 121 | 122 | # Programming cards 123 | 124 | ## Card record format 125 | 126 | The cards are programmed per the instructions at [Sonos Vinyl Emulator](https://github.com/hankhank10/vinylemulator). One minor difference with this program compared to Vinyl Emulator is that this program turns off shuffle, repeat, and crossfade whenever new music is queued by default. This is configurable in `usersettings.json`, you can turn off this behaviour by adding and/or setting `reset_repeat`, `reset_shuffle` and, `reset_crossfade` parameters to False. You can also enable cross fade, shuffle, or repeat on a card-by-card basis by adding records to enable these features to the card. 127 | 128 | ## Writing cards 129 | 130 | You can probably use the card reader/writer you plan to use to write the cards using software like [NFC Tools](https://www.wakdev.com/en/apps/nfc-tools-pc-mac.html) on your Mac or PC. I like to use my iPhone. Most modern smartphones can read and write NFC with the right app. 131 | 132 | It's important that before you write, the card is properly erased and formatted. On my iPhone, I format the cards for NDEF using "NXP Tagwriter." Once the cards are formatted, I use NFC Tools on iOS to write the record(s). 133 | -------------------------------------------------------------------------------- /__mocks__/nfccard-tool.js: -------------------------------------------------------------------------------- 1 | const NfccardTool = { 2 | parseInfo: jest.fn(), 3 | isFormatedAsNDEF: jest.fn(), 4 | hasReadPermissions: jest.fn(), 5 | hasNDEFMessage: jest.fn(), 6 | getNDEFMessageLengthToRead: jest.fn(), 7 | parseNDEF: jest.fn(), 8 | }; 9 | 10 | export default NfccardTool; 11 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] 3 | } 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { NFC } from 'nfc-pcsc'; 2 | import sonos_nfc from './lib/sonos_nfc.js'; 3 | 4 | const nfc = new NFC(); 5 | 6 | sonos_nfc(nfc); 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/private/var/folders/fp/mgwwpykn2vs50jdfvnyyt0nr0000gp/T/jest_dy", 15 | 16 | // Automatically clear mock calls and instances between every test 17 | // clearMocks: false, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | collectCoverage: true, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | collectCoverageFrom: ['**/lib/*.js', '!**/__tests__/**'], 24 | 25 | // The directory where Jest should output its coverage files 26 | coverageDirectory: 'coverage', 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | // coveragePathIgnorePatterns: [ 30 | // "/node_modules/" 31 | // ], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | // coverageProvider: "babel", 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | // coverageReporters: [ 38 | // "json", 39 | // "text", 40 | // "lcov", 41 | // "clover" 42 | // ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // Force coverage collection from ignored files using an array of glob patterns 54 | // forceCoverageMatch: [], 55 | 56 | // A path to a module which exports an async function that is triggered once before all test suites 57 | // globalSetup: undefined, 58 | 59 | // A path to a module which exports an async function that is triggered once after all test suites 60 | // globalTeardown: undefined, 61 | 62 | // A set of global variables that need to be available in all test environments 63 | // globals: {}, 64 | 65 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 66 | // maxWorkers: "50%", 67 | 68 | // An array of directory names to be searched recursively up from the requiring module's location 69 | // moduleDirectories: [ 70 | // "node_modules" 71 | // ], 72 | 73 | // An array of file extensions your modules use 74 | // moduleFileExtensions: [ 75 | // "js", 76 | // "jsx", 77 | // "ts", 78 | // "tsx", 79 | // "json", 80 | // "node" 81 | // ], 82 | 83 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 84 | // moduleNameMapper: {}, 85 | 86 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 87 | // modulePathIgnorePatterns: [], 88 | 89 | // Activates notifications for test results 90 | // notify: false, 91 | 92 | // An enum that specifies notification mode. Requires { notify: true } 93 | // notifyMode: "failure-change", 94 | 95 | // A preset that is used as a base for Jest's configuration 96 | // preset: undefined, 97 | 98 | // Run tests from one or more projects 99 | // projects: undefined, 100 | 101 | // Use this configuration option to add custom reporters to Jest 102 | // reporters: undefined, 103 | 104 | // Automatically reset mock state between every test 105 | // resetMocks: false, 106 | 107 | // Reset the module registry before running each individual test 108 | // resetModules: false, 109 | 110 | // A path to a custom resolver 111 | // resolver: undefined, 112 | 113 | // Automatically restore mock state between every test 114 | // restoreMocks: false, 115 | 116 | // The root directory that Jest should scan for tests and modules within 117 | // rootDir: undefined, 118 | 119 | // A list of paths to directories that Jest should use to search for files in 120 | // roots: [ 121 | // "" 122 | // ], 123 | 124 | // Allows you to use a custom runner instead of Jest's default test runner 125 | // runner: "jest-runner", 126 | 127 | // The paths to modules that run some code to configure or set up the testing environment before each test 128 | // setupFiles: [], 129 | 130 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 131 | setupFilesAfterEnv: ['./setup_tests.js'], 132 | 133 | // The number of seconds after which a test is considered as slow and reported as such in the results. 134 | // slowTestThreshold: 5, 135 | 136 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 137 | // snapshotSerializers: [], 138 | 139 | // The test environment that will be used for testing 140 | // testEnvironment: "jest-environment-node", 141 | 142 | // Options that will be passed to the testEnvironment 143 | // testEnvironmentOptions: {}, 144 | 145 | // Adds a location field to test results 146 | // testLocationInResults: false, 147 | 148 | // The glob patterns Jest uses to detect test files 149 | testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'], 150 | 151 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 152 | // testPathIgnorePatterns: [ 153 | // "/node_modules/" 154 | // ], 155 | 156 | // The regexp pattern or array of patterns that Jest uses to detect test files 157 | // testRegex: [], 158 | 159 | // This option allows the use of a custom results processor 160 | // testResultsProcessor: undefined, 161 | 162 | // This option allows use of a custom test runner 163 | // testRunner: "jest-circus/runner", 164 | 165 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 166 | // testURL: "http://localhost", 167 | 168 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 169 | // timers: "real", 170 | 171 | // A map from regular expressions to paths to transformers 172 | // transform: undefined, 173 | 174 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 175 | // transformIgnorePatterns: [ 176 | // "/node_modules/", 177 | // "\\.pnp\\.[^\\/]+$" 178 | // ], 179 | 180 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 181 | // unmockedModulePathPatterns: undefined, 182 | 183 | // Indicates whether each individual test should be reported during the run 184 | // verbose: undefined, 185 | 186 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 187 | // watchPathIgnorePatterns: [], 188 | 189 | // Whether to use watchman for file crawling 190 | // watchman: true, 191 | }; 192 | -------------------------------------------------------------------------------- /lib/__tests__/event_emitter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple event emitter used for testing, use the emit method to 3 | * simulate events in tests. 4 | */ 5 | class EventEmitter { 6 | events = {}; 7 | 8 | constructor(properties = {}) { 9 | Object.keys(properties).forEach((key) => { 10 | this[key] = properties[key]; 11 | }); 12 | } 13 | 14 | on(event, callback) { 15 | this.events[event] = Array.isArray(this.events[event]) 16 | ? [...this.events[event], callback] 17 | : [callback]; 18 | } 19 | 20 | emit(event, ...args) { 21 | if (!Array.isArray(this.events[event])) { 22 | return Promise.resolve(); 23 | } 24 | 25 | return Promise.all( 26 | this.events[event].map(async (callback) => { 27 | await callback(...args); 28 | }) 29 | ); 30 | } 31 | } 32 | 33 | export default EventEmitter; 34 | -------------------------------------------------------------------------------- /lib/__tests__/process_sonos_command.test.js: -------------------------------------------------------------------------------- 1 | import process_sonos_command, { 2 | get_sonos_url, 3 | } from '../process_sonos_command.js'; 4 | 5 | describe('process_sonos_command', () => { 6 | beforeEach(() => { 7 | fetch.resetMocks(); 8 | }); 9 | 10 | const identifier = 'my-awesome-url'; 11 | 12 | const commands = [ 13 | { 14 | service: 'Apple Music format #1', 15 | input: `apple:${identifier}`, 16 | expected_url: get_sonos_url(`applemusic/now/${identifier}`, 'applemusic'), 17 | }, 18 | { 19 | service: 'Apple Music format #2', 20 | input: `applemusic:${identifier}`, 21 | expected_url: get_sonos_url(`applemusic/now/${identifier}`, 'applemusic'), 22 | }, 23 | { 24 | service: 'BBC Sounds', 25 | input: `bbcsounds:${identifier}`, 26 | expected_url: get_sonos_url(`bbcsounds/play/${identifier}`, 'bbcsounds'), 27 | }, 28 | { 29 | service: 'Local Music Library', 30 | input: `http:${identifier}`, 31 | expected_url: get_sonos_url(`http:${identifier}`, 'completeurl'), 32 | }, 33 | { 34 | service: 'Spotify', 35 | input: `spotify:${identifier}`, 36 | expected_url: get_sonos_url( 37 | `spotify/now/spotify:${identifier}`, 38 | 'spotify' 39 | ), 40 | }, 41 | { 42 | service: 'Amazon Music', 43 | input: `amazonmusic:${identifier}`, 44 | expected_url: get_sonos_url( 45 | `amazonmusic/now/${identifier}`, 46 | 'amazonmusic' 47 | ), 48 | }, 49 | { 50 | service: 'TuneIn', 51 | input: `tunein:${identifier}`, 52 | expected_url: get_sonos_url(`tunein/play/${identifier}`, 'tunein'), 53 | }, 54 | { 55 | service: 'Favorite', 56 | input: `favorite:${identifier}`, 57 | expected_url: get_sonos_url(`favorite/${identifier}`, 'favorite'), 58 | }, 59 | { 60 | service: 'Sonos Playlist', 61 | input: `playlist:${identifier}`, 62 | expected_url: get_sonos_url(`playlist/${identifier}`, 'sonos_playlist'), 63 | }, 64 | ]; 65 | 66 | test.each(commands)( 67 | 'Processes $service URLs', 68 | async ({ input, expected_url }) => { 69 | fetch.mockResponse(() => { 70 | return Promise.resolve({ 71 | ok: true, 72 | body: JSON.stringify({ message: 'Hello from the mock' }), 73 | }); 74 | }); 75 | 76 | await process_sonos_command(input); 77 | 78 | expect(fetch).toHaveBeenNthCalledWith(5, expected_url); 79 | } 80 | ); 81 | 82 | test('Processes playback commands', async () => { 83 | fetch.mockResponse(() => { 84 | return Promise.resolve({ 85 | ok: true, 86 | body: JSON.stringify({ message: 'Hello from the mock' }), 87 | }); 88 | }); 89 | 90 | await process_sonos_command(`command:${identifier}`); 91 | 92 | const expected_url = get_sonos_url(identifier, 'command'); 93 | 94 | // This covers not resetting playback options when processing a command 95 | expect(fetch).toHaveBeenCalledTimes(1); 96 | expect(fetch).toHaveBeenCalledWith(expected_url); 97 | }); 98 | 99 | test('Processes room change commands', async () => { 100 | const log = jest.spyOn(console, 'log'); 101 | const sonos_room = 'Wine Cellar'; 102 | 103 | await process_sonos_command(`room:${sonos_room}`); 104 | 105 | expect(log).toHaveBeenCalledWith(`Sonos room changed to ${sonos_room}`); 106 | }); 107 | 108 | test('Ignores unknown services', async () => { 109 | const log = jest.spyOn(console, 'log'); 110 | 111 | await process_sonos_command(`nonexistingservice:${identifier}`); 112 | 113 | const expected_url = get_sonos_url('command', identifier); 114 | 115 | expect(log).toHaveBeenCalledWith( 116 | expect.stringContaining('Service type not recognised') 117 | ); 118 | expect(fetch).not.toHaveBeenCalled(); 119 | }); 120 | 121 | test('Playback options are reset when a new play request comes in', async () => { 122 | fetch.mockResponse(() => { 123 | return Promise.resolve({ 124 | ok: true, 125 | body: JSON.stringify({ message: 'Hello from the mock' }), 126 | }); 127 | }); 128 | 129 | await process_sonos_command(`spotify:${identifier}`); 130 | 131 | const expected_url = get_sonos_url('spotify', identifier); 132 | 133 | expect(fetch).toHaveBeenNthCalledWith(1, get_sonos_url('repeat/off')); 134 | expect(fetch).toHaveBeenNthCalledWith(2, get_sonos_url('shuffle/off')); 135 | expect(fetch).toHaveBeenNthCalledWith(3, get_sonos_url('crossfade/off')); 136 | expect(fetch).toHaveBeenNthCalledWith(4, get_sonos_url('clearqueue')); 137 | }); 138 | 139 | test('Throws when request is unsuccessful', async () => { 140 | fetch.mockResponse(() => { 141 | return Promise.resolve({ 142 | ok: false, 143 | status: 422, 144 | body: JSON.stringify({ error: 'Oh no' }), 145 | }); 146 | }); 147 | 148 | try { 149 | await process_sonos_command(`command:${identifier}`); 150 | } catch (error) { 151 | expect(error).toEqual( 152 | new Error(`Unexpected response while sending instruction: 422`) 153 | ); 154 | } 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /lib/__tests__/sonos_nfc.test.js: -------------------------------------------------------------------------------- 1 | import nfcCard from 'nfccard-tool'; 2 | import { NFC } from 'nfc-pcsc'; 3 | import process_sonos_command from '../process_sonos_command.js'; 4 | import sonos_nfc from '../sonos_nfc.js'; 5 | import EventEmitter from './event_emitter.js'; 6 | import { jest } from '@jest/globals'; 7 | 8 | jest.mock('../process_sonos_command.js'); 9 | 10 | describe('sonos_nfc', () => { 11 | beforeEach(() => { 12 | jest.resetAllMocks(); 13 | }); 14 | 15 | test('Listens for reader ready event', () => { 16 | // Setup 17 | const nfc = { 18 | on: jest.fn(), 19 | }; 20 | 21 | // Act 22 | sonos_nfc(nfc); 23 | 24 | // Assert 25 | expect(nfc.on).toBeCalledWith('reader', expect.any(Function)); 26 | }); 27 | 28 | test('Calls process_sonos_command when card text message is successfully processed', async () => { 29 | // Setup 30 | const nfc = new EventEmitter(); 31 | const command = 'spotify:abc123'; 32 | const mockReader = new EventEmitter({ 33 | read: () => Promise.resolve(command), 34 | reader: { 35 | name: 'Mock Reader', 36 | }, 37 | }); 38 | const card = { 39 | type: 'ntag', 40 | uid: '043A98CABB2B80', 41 | }; 42 | 43 | nfcCard.isFormatedAsNDEF.mockImplementation(() => true); 44 | nfcCard.hasReadPermissions.mockImplementation(() => true); 45 | nfcCard.hasNDEFMessage.mockImplementation(() => true); 46 | nfcCard.parseNDEF.mockImplementation((msg) => [ 47 | { 48 | type: 'text', 49 | text: msg, 50 | }, 51 | ]); 52 | 53 | // Act 54 | sonos_nfc(nfc); 55 | await nfc.emit('reader', mockReader); 56 | await mockReader.emit('card', card); 57 | 58 | // Assert 59 | expect(process_sonos_command).toHaveBeenCalledWith(command); 60 | }); 61 | 62 | test('Calls process_sonos_command when card URI message is successfully processed', async () => { 63 | // Setup 64 | const nfc = new EventEmitter(); 65 | const command = 'spotify:abc123'; 66 | const mockReader = new EventEmitter({ 67 | read: () => Promise.resolve(command), 68 | reader: { 69 | name: 'Mock Reader', 70 | }, 71 | }); 72 | const card = { 73 | type: 'ntag', 74 | uid: '043A98CABB2B80', 75 | }; 76 | 77 | nfcCard.isFormatedAsNDEF.mockImplementation(() => true); 78 | nfcCard.hasReadPermissions.mockImplementation(() => true); 79 | nfcCard.hasNDEFMessage.mockImplementation(() => true); 80 | nfcCard.parseNDEF.mockImplementation((msg) => [ 81 | { 82 | type: 'uri', 83 | uri: msg, 84 | }, 85 | ]); 86 | 87 | // Act 88 | sonos_nfc(nfc); 89 | await nfc.emit('reader', mockReader); 90 | await mockReader.emit('card', card); 91 | 92 | // Assert 93 | expect(process_sonos_command).toHaveBeenCalledWith(command); 94 | }); 95 | 96 | test('Logs error message when card format is invalid', async () => { 97 | // Setup 98 | const log = jest.spyOn(console, 'log'); 99 | const nfc = new EventEmitter(); 100 | const mockReader = new EventEmitter({ 101 | read: () => Promise.resolve({}), 102 | reader: { 103 | name: 'Mock Reader', 104 | }, 105 | }); 106 | const card = { 107 | type: 'ntag', 108 | uid: '043A98CABB2B80', 109 | }; 110 | 111 | // Act 112 | sonos_nfc(nfc); 113 | await nfc.emit('reader', mockReader); 114 | await mockReader.emit('card', card); 115 | 116 | // Assert 117 | expect(log).toHaveBeenCalledWith( 118 | expect.stringContaining('Could not parse anything from this tag') 119 | ); 120 | }); 121 | 122 | test('Logs message when card is removed', async () => { 123 | // Setup 124 | const log = jest.spyOn(console, 'log'); 125 | const nfc = new EventEmitter(); 126 | const mockReader = new EventEmitter({ 127 | read: jest.fn((start, end) => Promise.resolve(new Buffer())), 128 | reader: { 129 | name: 'Mock Reader', 130 | }, 131 | }); 132 | const card = { 133 | type: 'ntag', 134 | uid: '043A98CABB2B80', 135 | }; 136 | 137 | // Act 138 | sonos_nfc(nfc); 139 | await nfc.emit('reader', mockReader); 140 | await mockReader.emit('card.off', card); 141 | 142 | // Assert 143 | expect(log).toHaveBeenNthCalledWith( 144 | 3, 145 | `${mockReader.reader.name}: ${card.type} with UID ${card.uid} removed` 146 | ); 147 | }); 148 | 149 | test('Logs error message when card can’t be read', async () => { 150 | // Setup 151 | const log = jest.spyOn(console, 'error'); 152 | const nfc = new EventEmitter(); 153 | const error = 'Nope, did not work'; 154 | const mockReader = new EventEmitter({ 155 | read: () => Promise.reject(error), 156 | reader: { 157 | name: 'Mock Reader', 158 | }, 159 | }); 160 | const card = { 161 | type: 'ntag', 162 | uid: '043A98CABB2B80', 163 | }; 164 | 165 | // Act 166 | sonos_nfc(nfc); 167 | await nfc.emit('reader', mockReader); 168 | await mockReader.emit('card', card); 169 | 170 | // Assert 171 | expect(log).toHaveBeenCalledWith(error); 172 | }); 173 | 174 | test('Logs message when card is removed', async () => { 175 | // Setup 176 | const log = jest.spyOn(console, 'log'); 177 | const nfc = new EventEmitter(); 178 | const mockReader = new EventEmitter({ 179 | read: jest.fn((start, end) => Promise.resolve(new Buffer())), 180 | reader: { 181 | name: 'Mock Reader', 182 | }, 183 | }); 184 | const card = { 185 | type: 'ntag', 186 | uid: '043A98CABB2B80', 187 | }; 188 | 189 | // Act 190 | sonos_nfc(nfc); 191 | await nfc.emit('reader', mockReader); 192 | await mockReader.emit('card.off', card); 193 | 194 | // Assert 195 | expect(log).toHaveBeenNthCalledWith( 196 | 3, 197 | `${mockReader.reader.name}: ${card.type} with UID ${card.uid} removed` 198 | ); 199 | }); 200 | 201 | test('Logs message when reader throws error', async () => { 202 | // Setup 203 | const log = jest.spyOn(console, 'log'); 204 | const nfc = new EventEmitter(); 205 | const mockReader = new EventEmitter({ 206 | read: jest.fn((start, end) => Promise.resolve(new Buffer())), 207 | reader: { 208 | name: 'Mock Reader', 209 | }, 210 | }); 211 | const error = 'Nope, did not work'; 212 | 213 | // Act 214 | sonos_nfc(nfc); 215 | await nfc.emit('reader', mockReader); 216 | await mockReader.emit('error', error); 217 | 218 | // Assert 219 | expect(log).toHaveBeenNthCalledWith( 220 | 3, 221 | `${mockReader.reader.name} an error occurred`, 222 | error 223 | ); 224 | }); 225 | 226 | test('Logs message when reader is disconnected', async () => { 227 | // Setup 228 | const log = jest.spyOn(console, 'log'); 229 | const nfc = new EventEmitter(); 230 | const mockReader = new EventEmitter({ 231 | read: jest.fn((start, end) => Promise.resolve(new Buffer())), 232 | reader: { 233 | name: 'Mock Reader', 234 | }, 235 | }); 236 | 237 | // Act 238 | sonos_nfc(nfc); 239 | await nfc.emit('reader', mockReader); 240 | await mockReader.emit('end'); 241 | 242 | // Assert 243 | expect(log).toHaveBeenNthCalledWith( 244 | 3, 245 | `${mockReader.reader.name} device removed` 246 | ); 247 | }); 248 | 249 | test('Logs message when NFC library returns error', async () => { 250 | // Setup 251 | const log = jest.spyOn(console, 'log'); 252 | const nfc = new EventEmitter(); 253 | const error = 'Nope, did not work'; 254 | 255 | // Act 256 | sonos_nfc(nfc); 257 | await nfc.emit('error', error); 258 | 259 | // Assert 260 | expect(log).toHaveBeenNthCalledWith(2, 'an NFC error occurred', error); 261 | }); 262 | }); 263 | -------------------------------------------------------------------------------- /lib/process_sonos_command.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import fs from 'fs'; 3 | 4 | var { sonos_room, sonos_http_api, reset_repeat, reset_shuffle, reset_crossfade } = JSON.parse( 5 | fs.readFileSync('usersettings.json', 'utf-8') 6 | ); 7 | 8 | export const get_sonos_url = (sonos_instruction, service_type) => { 9 | if (service_type == 'completeurl') { 10 | return sonos_instruction; 11 | } 12 | 13 | return sonos_http_api + '/' + sonos_room + '/' + sonos_instruction; 14 | }; 15 | 16 | export default async function process_sonos_command(received_text) { 17 | let service_type, sonos_instruction; 18 | let received_text_lower = received_text.toLowerCase(); 19 | 20 | if (received_text_lower.startsWith('apple:')) { 21 | service_type = 'applemusic'; 22 | sonos_instruction = 'applemusic/now/' + received_text.slice(6); 23 | } else if (received_text_lower.startsWith('applemusic:')) { 24 | service_type = 'applemusic'; 25 | sonos_instruction = 'applemusic/now/' + received_text.slice(11); 26 | } else if (received_text_lower.startsWith('bbcsounds:')) { 27 | service_type = 'bbcsounds'; 28 | sonos_instruction = 'bbcsounds/play/' + received_text.slice(10); 29 | } else if (received_text_lower.startsWith('http')) { 30 | service_type = 'completeurl'; 31 | sonos_instruction = received_text; 32 | } else if (received_text_lower.startsWith('spotify:')) { 33 | service_type = 'spotify'; 34 | sonos_instruction = 'spotify/now/' + received_text; 35 | } else if (received_text_lower.startsWith('tunein:')) { 36 | service_type = 'tunein'; 37 | sonos_instruction = 'tunein/play/' + received_text.slice(7); 38 | } else if (received_text_lower.startsWith('favorite:')) { 39 | service_type = 'favorite'; 40 | sonos_instruction = 'favorite/' + received_text.slice(9); 41 | } else if (received_text_lower.startsWith('amazonmusic:')) { 42 | service_type = 'amazonmusic'; 43 | sonos_instruction = 'amazonmusic/now/' + received_text.slice(12); 44 | } else if (received_text_lower.startsWith('playlist:')) { 45 | service_type = 'sonos_playlist'; 46 | sonos_instruction = 'playlist/' + received_text.slice(9); 47 | } else if (received_text_lower.startsWith('command:')) { 48 | service_type = 'command'; 49 | sonos_instruction = received_text.slice(8); 50 | } else if (received_text_lower.startsWith('room:')) { 51 | sonos_room = received_text.slice(5); 52 | console.log(`Sonos room changed to ${sonos_room}`); 53 | return; 54 | } 55 | 56 | if (!service_type) { 57 | console.log( 58 | 'Service type not recognised. Text should begin ' + 59 | "'spotify', 'tunein', 'favorite', 'amazonmusic', 'apple'/'applemusic', 'bbcsounds', 'command', 'http', 'playlist', or 'room'." 60 | ); 61 | return; 62 | } 63 | 64 | console.log("Detected '%s' service request", service_type); 65 | if (service_type != 'command') { 66 | let res; 67 | if (reset_repeat) { 68 | console.log('Resetting repeat'); 69 | res = await fetch(get_sonos_url('repeat/off')); 70 | if (!res.ok) 71 | throw new Error( 72 | `Unexpected response while turning repeat off: ${res.status}` 73 | ); 74 | } else { 75 | console.log('Skipping resetting repeat'); 76 | } 77 | await new Promise((resolve) => setTimeout(resolve, 200)); 78 | 79 | if (reset_shuffle) { 80 | console.log('Resetting shuffle'); 81 | res = await fetch(get_sonos_url('shuffle/off')); 82 | if (!res.ok) 83 | throw new Error( 84 | `Unexpected response while turning shuffle off: ${res.status}` 85 | ); 86 | } else { 87 | console.log('Skipping resetting shuffle'); 88 | } 89 | await new Promise((resolve) => setTimeout(resolve, 200)); 90 | 91 | if (reset_crossfade) { 92 | console.log('Resetting crossfade'); 93 | res = await fetch(get_sonos_url('crossfade/off')); 94 | if (!res.ok) 95 | throw new Error( 96 | `Unexpected response while turning crossfade off: ${res.status}` 97 | ); 98 | } else { 99 | console.log('Skipping resetting scrossfade'); 100 | } 101 | await new Promise((resolve) => setTimeout(resolve, 200)); 102 | 103 | res = await fetch(get_sonos_url('clearqueue')); 104 | console.log('Clearing Sonos queue'); 105 | if (!res.ok) 106 | throw new Error( 107 | `Unexpected response while clearing queue: ${res.status}` 108 | ); 109 | } 110 | 111 | const urltoget = get_sonos_url(sonos_instruction, service_type); 112 | 113 | // Perform the requested action on the sonos API 114 | console.log('Fetching URL via HTTP api: %s', urltoget); 115 | const res = await fetch(urltoget); 116 | 117 | if (!res.ok) { 118 | throw new Error( 119 | `Unexpected response while sending instruction: ${res.status}` 120 | ); 121 | } 122 | 123 | console.log('Sonos API reports: ', await res.json()); 124 | 125 | // Wait a bit before processing next record so the API has time to respond to first command 126 | // e.g. want to seek on a new queue -- need the new queue to exist. Is there a way to check/confirm 127 | // with Sonos that a prior command is complete? I'm not sure if this a sonos thing or the http API 128 | // sometimes throwing commands into the ether while Sonos is busy. 129 | await new Promise((resolve) => setTimeout(resolve, 200)); 130 | } 131 | -------------------------------------------------------------------------------- /lib/sonos_nfc.js: -------------------------------------------------------------------------------- 1 | import nfcCard from 'nfccard-tool'; 2 | import process_sonos_command from './process_sonos_command.js'; 3 | 4 | const sonos_nfc = (nfc) => { 5 | console.log( 6 | 'Control your Sonos with NFC cards. Searching for PCSC-compatible NFC reader devices...' 7 | ); 8 | 9 | nfc.on('reader', (reader) => { 10 | console.log(`${reader.reader.name} device attached`); 11 | 12 | reader.on('card', async (card) => { 13 | // card is object containing following data 14 | // String standard: TAG_ISO_14443_3 (standard nfc tags like MIFARE Ultralight) or TAG_ISO_14443_4 (Android HCE and others) 15 | // String type: same as standard 16 | // Buffer atr 17 | 18 | console.log( 19 | `${reader.reader.name} detected ${card.type} with UID ${card.uid}` 20 | ); 21 | 22 | try { 23 | /** 24 | * 1 - READ HEADER 25 | * Read from block 0 to block 4 (20 bytes length) in order to parse tag information 26 | * Block 4 is the first data block -- should have the TLV info 27 | */ 28 | const cardHeader = await reader.read(0, 20); 29 | 30 | const tag = nfcCard.parseInfo(cardHeader); 31 | // console.log('tag info:', JSON.stringify(tag)) 32 | 33 | /** 34 | * 2 - Read the NDEF message and parse it if it's supposed there is one 35 | * The NDEF message must begin in block 4 -- no locked bits, etc. 36 | * Make sure cards are initialized before writing. 37 | */ 38 | 39 | if ( 40 | nfcCard.isFormatedAsNDEF() && 41 | nfcCard.hasReadPermissions() && 42 | nfcCard.hasNDEFMessage() 43 | ) { 44 | // Read the appropriate length to get the NDEF message as buffer 45 | const NDEFRawMessage = await reader.read( 46 | 4, 47 | nfcCard.getNDEFMessageLengthToRead() 48 | ); // starts reading in block 0 until end 49 | 50 | // Parse the buffer as a NDEF raw message 51 | const NDEFMessage = nfcCard.parseNDEF(NDEFRawMessage); 52 | 53 | // console.log('NDEFMessage:', NDEFMessage) 54 | 55 | for (const record of NDEFMessage) { 56 | let service_type, sonos_instruction; 57 | switch (record.type) { 58 | case 'uri': 59 | record.text = record.uri; 60 | case 'text': 61 | const received_text = record.text; 62 | console.log('Read from NFC tag with message: ', received_text); 63 | 64 | await process_sonos_command(received_text); 65 | } 66 | } 67 | } else { 68 | console.log( 69 | 'Could not parse anything from this tag: \n The tag is either empty, locked, has a wrong NDEF format or is unreadable.' 70 | ); 71 | } 72 | } catch (err) { 73 | console.error(err.toString()); 74 | } 75 | }); 76 | 77 | reader.on('card.off', (card) => { 78 | console.log( 79 | `${reader.reader.name}: ${card.type} with UID ${card.uid} removed` 80 | ); 81 | }); 82 | 83 | reader.on('error', (err) => { 84 | console.log(`${reader.reader.name} an error occurred`, err); 85 | }); 86 | 87 | reader.on('end', () => { 88 | console.log(`${reader.reader.name} device removed`); 89 | }); 90 | }); 91 | 92 | nfc.on('error', (err) => { 93 | console.log('an NFC error occurred', err); 94 | }); 95 | }; 96 | 97 | export default sonos_nfc; 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sonos-nfc", 3 | "version": "0.1.0", 4 | "description": "Use NFC tags to control Sonos - a node app inspired by and compatible with hankhank10/vinylemulator, with better NFC reader compatibility", 5 | "main": "sonos_nfc.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "jest", 9 | "start-all": "concurrently --kill-others \"npm start\" \"npm explore sonos-http-api -- npm start\"", 10 | "start": "node index.js", 11 | "prepare": "husky install", 12 | "format": "prettier --write . && git add -A ." 13 | }, 14 | "author": "Ryan Olf", 15 | "license": "MIT", 16 | "dependencies": { 17 | "concurrently": "^8.2.2", 18 | "nfc-pcsc": "^0.8.1", 19 | "nfccard-tool": "^0.1.1", 20 | "node-fetch": "^3.3.2", 21 | "sonos-http-api": "jishi/node-sonos-http-api" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.23.7", 25 | "@babel/preset-env": "^7.23.8", 26 | "babel-jest": "^29.7.0", 27 | "babel-plugin-transform-amd-to-commonjs": "^1.6.0", 28 | "husky": "^8.0.3", 29 | "jest": "^29.7.0", 30 | "jest-fetch-mock": "^3.0.3", 31 | "prettier": "^3.2.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /setup_tests.js: -------------------------------------------------------------------------------- 1 | import { enableFetchMocks } from 'jest-fetch-mock'; 2 | 3 | enableFetchMocks(); 4 | -------------------------------------------------------------------------------- /usersettings.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "sonos_http_api": "http://localhost:5005", 3 | "sonos_room": "Living Room", 4 | "reset_repeat": true, 5 | "reset_shuffle": true, 6 | "reset_crossfade": true 7 | } 8 | --------------------------------------------------------------------------------