├── .sonarcloud.properties ├── Tests ├── Integration │ ├── back.test │ ├── mute.test │ ├── next.test │ ├── play.test │ ├── stop.test │ ├── pause.test │ ├── unmute.test │ ├── tuneIn.test │ ├── getVolume.test │ ├── discover.test │ ├── getTuneInFavorites.test │ ├── setVolume.test │ ├── setSource.test │ ├── getSources.test │ ├── getEnabledSources.test │ ├── adjustVolume.test │ ├── selectDevice.test │ └── TestRunner.sh ├── LinuxMain.swift └── RemoteCoreTests │ ├── XCTestManifests.swift │ └── RemoteCoreTests.swift ├── .gitignore ├── tty.gif ├── .github └── workflows │ └── swift.yml ├── Sources ├── RemoteCore │ ├── BeoplaySource.swift │ ├── BeoplayBrowser.swift │ ├── RemoteAdminControl.swift │ ├── NotificationSession.swift │ ├── NotificationBridge.swift │ └── RemoteControl.swift ├── RemoteCLI │ ├── ConsoleDeviceLocator.swift │ ├── main.swift │ ├── SerializedDeviceLocator.swift │ ├── Interactive.swift │ └── CommandLineTool.swift └── Emulator │ ├── AsyncNotificationResponse.swift │ ├── DefaultRouter.swift │ ├── AsyncResponseHandler.swift │ └── DeviceEmulator.swift ├── Makefile ├── .vscode └── launch.json ├── LICENSE ├── Package.resolved ├── Package.swift └── README.md /.sonarcloud.properties: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Tests/Integration/back.test: -------------------------------------------------------------------------------- 1 | beoplay-cli back 2 | -------------------------------------------------------------------------------- /Tests/Integration/mute.test: -------------------------------------------------------------------------------- 1 | beoplay-cli mute 2 | -------------------------------------------------------------------------------- /Tests/Integration/next.test: -------------------------------------------------------------------------------- 1 | beoplay-cli next 2 | -------------------------------------------------------------------------------- /Tests/Integration/play.test: -------------------------------------------------------------------------------- 1 | beoplay-cli play 2 | -------------------------------------------------------------------------------- /Tests/Integration/stop.test: -------------------------------------------------------------------------------- 1 | beoplay-cli stop 2 | -------------------------------------------------------------------------------- /Tests/Integration/pause.test: -------------------------------------------------------------------------------- 1 | beoplay-cli pause 2 | -------------------------------------------------------------------------------- /Tests/Integration/unmute.test: -------------------------------------------------------------------------------- 1 | beoplay-cli unmute 2 | -------------------------------------------------------------------------------- /Tests/Integration/tuneIn.test: -------------------------------------------------------------------------------- 1 | beoplay-cli tuneIn s24861 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | -------------------------------------------------------------------------------- /Tests/Integration/getVolume.test: -------------------------------------------------------------------------------- 1 | beoplay-cli getVolume | grep 10 2 | -------------------------------------------------------------------------------- /tty.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlk/beoplay-macos-remote-cli/HEAD/tty.gif -------------------------------------------------------------------------------- /Tests/Integration/discover.test: -------------------------------------------------------------------------------- 1 | beoplay-cli discover | grep "$BEOPLAY_NAME" 2 | -------------------------------------------------------------------------------- /Tests/Integration/getTuneInFavorites.test: -------------------------------------------------------------------------------- 1 | beoplay-cli getTuneInFavorites | grep DR 2 | -------------------------------------------------------------------------------- /Tests/Integration/setVolume.test: -------------------------------------------------------------------------------- 1 | beoplay-cli setVolume 42 2 | beoplay-cli getVolume | grep 42 3 | -------------------------------------------------------------------------------- /Tests/Integration/setSource.test: -------------------------------------------------------------------------------- 1 | beoplay-cli setSource "radio:1234.1234567.12345678@products.bang-olufsen.com" 2 | -------------------------------------------------------------------------------- /Tests/Integration/getSources.test: -------------------------------------------------------------------------------- 1 | beoplay-cli getSources | grep "radio:1234.1234567.12345678@products.bang-olufsen.com" 2 | -------------------------------------------------------------------------------- /Tests/Integration/getEnabledSources.test: -------------------------------------------------------------------------------- 1 | beoplay-cli getEnabledSources | grep "radio:1234.1234567.12345678@products.bang-olufsen.com" 2 | -------------------------------------------------------------------------------- /Tests/Integration/adjustVolume.test: -------------------------------------------------------------------------------- 1 | beoplay-cli setVolume 42 2 | beoplay-cli adjustVolume -10 | grep 32 3 | beoplay-cli getVolume | grep 32 4 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import RemoteCoreTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += RemoteCoreTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/Integration/selectDevice.test: -------------------------------------------------------------------------------- 1 | LOCAL_BEOPLAY_NAME=$BEOPLAY_NAME 2 | unset BEOPLAY_NAME 3 | beoplay-cli selectDevice "$LOCAL_BEOPLAY_NAME" | grep "device endpoint successfully located" 4 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: macOS-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Test 11 | run: make test test-integration 12 | -------------------------------------------------------------------------------- /Tests/RemoteCoreTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(RemoteCoreTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Sources/RemoteCore/BeoplaySource.swift: -------------------------------------------------------------------------------- 1 | 2 | public struct BeoplaySource { 3 | public let id: String 4 | public let sourceType: String 5 | public let category: String 6 | public let friendlyName: String 7 | public let borrowed: Bool // 🔗 8 | public let productJid: String 9 | public let productFriendlyName: String 10 | } 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | swift build 3 | test: 4 | swift test 5 | test-integration: build 6 | cd Tests/Integration && ./TestRunner.sh 7 | release: 8 | swift build -c release 9 | install: release 10 | cp .build/release/beoplay-cli /usr/local/bin/beoplay-cli 11 | uninstall: 12 | rm /usr/local/bin/beoplay-cli 13 | clean: 14 | swift package clean 15 | flush-dns: 16 | sudo killall -HUP mDNSResponder 17 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug", 11 | "program": "${workspaceFolder}/.build/debug/beoplay-cli", 12 | "args": ["setVolume", "48"], // "getVolume" 13 | "cwd": "${workspaceFolder}" 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /Sources/RemoteCLI/ConsoleDeviceLocator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class ConsoleDeviceLocator : NSObject, NetServiceBrowserDelegate, NetServiceDelegate { 4 | private var devices = [NetService]() 5 | 6 | func netServiceBrowser(_ browser: NetServiceBrowser, didRemove device: NetService, moreComing: Bool) { 7 | print("- \(device.name)") 8 | } 9 | 10 | func netServiceBrowser(_ browser: NetServiceBrowser, didFind device: NetService, moreComing: Bool) { 11 | // need to keep a reference to the device object for it to be resolved 12 | self.devices.append(device) 13 | device.delegate = self 14 | device.resolve(withTimeout: 5.0) 15 | } 16 | 17 | func netServiceDidResolveAddress(_ device: NetService) { 18 | print("+ \"\(device.name)\"\thttp://\(device.hostName!):\(device.port)") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/RemoteCore/BeoplayBrowser.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class BeoplayBrowser { 4 | private var queue = DispatchQueue(label: "beoplay-browser", attributes: .concurrent) 5 | private var browser = NetServiceBrowser() 6 | 7 | public func searchForDevices(delegate: NetServiceBrowserDelegate, withTimeout: TimeInterval? = nil, next: (() -> ())? = nil) { 8 | 9 | if withTimeout != nil { 10 | self.queue.asyncAfter(deadline: .now() + withTimeout!) { 11 | self.browser.stop() 12 | next?() 13 | } 14 | } 15 | 16 | self.queue.async { 17 | self.browser.delegate = delegate 18 | self.browser.schedule(in: RunLoop.current, forMode: RunLoop.Mode.default) 19 | self.browser.searchForServices(ofType: "_beoremote._tcp.", inDomain: "local.") 20 | RunLoop.current.run() 21 | } 22 | } 23 | 24 | public func stop() { 25 | self.browser.stop() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/RemoteCLI/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RemoteCore 3 | 4 | let tool = CommandLineTool() 5 | tool.enablePiping() 6 | 7 | func setupEndpoint() { 8 | if let name = ProcessInfo.processInfo.environment["BEOPLAY_NAME"]{ 9 | tool.selectDevice(name) 10 | } else if let host = ProcessInfo.processInfo.environment["BEOPLAY_HOST"] { 11 | var port: Int? 12 | if let strPort = ProcessInfo.processInfo.environment["BEOPLAY_PORT"] { 13 | port = Int(strPort) 14 | } 15 | tool.setEndpoint(host: host, port: port ?? 8080) 16 | } 17 | } 18 | 19 | var args = CommandLine.arguments; 20 | if (args.indices.contains(1)) { 21 | args.removeFirst(1) 22 | let command: String? = args.indices.contains(0) ? args[0] : nil 23 | let option: String? = args.indices.contains(1) ? args[1] : nil 24 | 25 | if tool.endpointIsRequired(command) { 26 | setupEndpoint() 27 | } 28 | 29 | let code = tool.run(command, option) 30 | exit(code) 31 | 32 | } else { 33 | setupEndpoint() 34 | let interactive = Interactive(tool) 35 | interactive.run() 36 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Thomas L. Kjeldsen 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 | -------------------------------------------------------------------------------- /Sources/Emulator/AsyncNotificationResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Ambassador 3 | 4 | public class AsyncNotificationResponse : WebApp { 5 | private let lock = DispatchSemaphore(value: 1) 6 | private var counter = 0 7 | let emulator: DeviceEmulator 8 | 9 | public init(emulator: DeviceEmulator) { 10 | self.emulator = emulator 11 | } 12 | 13 | // https://gist.github.com/nestserau/ce8f5e5d3f68781732374f7b1c352a5a 14 | public func incrementAndGetCounter() -> Int { 15 | lock.wait() 16 | defer { lock.signal() } 17 | counter += 1 18 | return counter 19 | } 20 | 21 | public func app( 22 | _ environ: [String: Any], 23 | startResponse: ((String, [(String, String)]) -> Void), 24 | sendBody: @escaping ((Data) -> Void) 25 | ) { 26 | startResponse("200 OK", [("contentType", "application/json")]) 27 | let handler = AsyncResponseHandler(app: self, environ: environ, sendBody: sendBody) 28 | handler.sendVolume() 29 | emulator.addObserver(observer: handler) 30 | 31 | // Push out progress updates for 5 minutes 32 | // See https://en.wikipedia.org/wiki/Push_technology#Long_polling 33 | handler.startProgressLoop(limit: 5*60) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/Integration/TestRunner.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | total=0 4 | passed=0 5 | failed=0 6 | 7 | echo "======================================================" 8 | echo "= Running integration tests" 9 | echo "======================================================" 10 | 11 | export PATH=../../.build/debug/:$PATH 12 | export BEOPLAY_NAME="IntegrationTestDevice" 13 | 14 | for integrationTest in *.test; do 15 | (beoplay-cli emulator "$BEOPLAY_NAME" 2>&1 >/dev/null) & 16 | 17 | output=$(./$integrationTest 2>&1) 18 | testResult=$? 19 | 20 | kill $(jobs -rp) 21 | wait $(jobs -rp) 2>/dev/null 22 | 23 | if [ "$testResult" == "0" ]; then 24 | echo "= PASS: $integrationTest" 25 | passed=$(($passed+1)) 26 | else 27 | echo "= FAIL: $integrationTest" 28 | echo "$output" 29 | echo "" 30 | failed=$(($failed+1)) 31 | fi 32 | 33 | total=$(($total+1)) 34 | done 35 | 36 | echo "======================================================" 37 | 38 | if [ "$failed" == "0" ]; then 39 | echo "= PASSED: All test cases completed successfully." 40 | else 41 | echo "= FAILED: Not all test cases completed successfully." 42 | fi 43 | 44 | echo "======================================================" 45 | echo "= Failed: $failed" 46 | echo "= Passed: $passed" 47 | echo "= Total: $total" 48 | echo "======================================================" 49 | 50 | exit $failed 51 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Ambassador", 6 | "repositoryURL": "https://github.com/Envoy/Ambassador", 7 | "state": { 8 | "branch": null, 9 | "revision": "447cf56f692999153bd261b1775ea4a58f7f7be9", 10 | "version": "4.0.5" 11 | } 12 | }, 13 | { 14 | "package": "Embassy", 15 | "repositoryURL": "https://github.com/envoy/Embassy.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "189436100c00efbf5fb2653fe7972a9371db0a91", 19 | "version": "4.1.1" 20 | } 21 | }, 22 | { 23 | "package": "LineNoise", 24 | "repositoryURL": "https://github.com/tlk/linenoise-swift", 25 | "state": { 26 | "branch": null, 27 | "revision": "cbf0a35c6e159e4fe6a03f76c8a17ef08e907b0e", 28 | "version": "0.0.4" 29 | } 30 | }, 31 | { 32 | "package": "Nimble", 33 | "repositoryURL": "https://github.com/Quick/Nimble.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "7a46a5fc86cb917f69e3daf79fcb045283d8f008", 37 | "version": "8.1.2" 38 | } 39 | }, 40 | { 41 | "package": "SwiftyJSON", 42 | "repositoryURL": "https://github.com/IBM-Swift/SwiftyJSON", 43 | "state": { 44 | "branch": null, 45 | "revision": "f9b4754017cfad4701f0838b2e507b442eaca70a", 46 | "version": "17.0.5" 47 | } 48 | } 49 | ] 50 | }, 51 | "version": 1 52 | } 53 | -------------------------------------------------------------------------------- /Sources/RemoteCLI/SerializedDeviceLocator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class SerializedDeviceLocator : NSObject, NetServiceBrowserDelegate, NetServiceDelegate { 4 | private let queue = DispatchQueue(label: "beoplay-single-device-locator") 5 | private var devices = [NetService]() 6 | private var isStopped = false 7 | 8 | private var didFind: (NetService) -> () 9 | private var didStop: () -> () 10 | 11 | public init(didFind: @escaping (NetService) -> (), didStop: @escaping () -> ()) { 12 | self.didFind = didFind 13 | self.didStop = didStop 14 | } 15 | 16 | private func stop() { 17 | self.queue.async { 18 | if self.isStopped { 19 | return 20 | } 21 | 22 | self.isStopped = true 23 | self.didStop() 24 | } 25 | } 26 | 27 | func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String : NSNumber]) { 28 | self.stop() 29 | } 30 | 31 | func netServiceBrowserDidStopSearch(_ browser: NetServiceBrowser) { 32 | self.stop() 33 | } 34 | 35 | func netServiceBrowser(_ browser: NetServiceBrowser, didFind device: NetService, moreComing: Bool) { 36 | // need to keep a reference to the device object for it to be resolved 37 | self.devices.append(device) 38 | device.delegate = self 39 | device.resolve(withTimeout: 5.0) 40 | } 41 | 42 | func netService(_ sender: NetService, didNotResolve errorDict: [String : NSNumber]) { 43 | self.stop() 44 | } 45 | 46 | func netServiceDidResolveAddress(_ device: NetService) { 47 | self.queue.async { 48 | self.didFind(device) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "BeoplayRemote", 8 | products: [ 9 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 10 | .library( 11 | name: "BeoplayRemoteCore", 12 | targets: ["RemoteCore"]), 13 | .library( 14 | name: "Emulator", 15 | targets: ["Emulator"]), 16 | .executable( 17 | name: "beoplay-cli", 18 | targets: ["RemoteCLI"]), 19 | ], 20 | dependencies: [ 21 | // Dependencies declare other packages that this package depends on. 22 | // .package(url: /* package url */, from: "1.0.0"), 23 | .package(url: "https://github.com/IBM-Swift/SwiftyJSON", from: "17.0.5"), 24 | .package(url: "https://github.com/tlk/linenoise-swift", from: "0.0.4"), 25 | .package(url: "https://github.com/Envoy/Ambassador", from: "4.0.5"), 26 | ], 27 | targets: [ 28 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 29 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 30 | .target( 31 | name: "RemoteCLI", 32 | dependencies: ["RemoteCore", "Emulator", "LineNoise"]), 33 | .target( 34 | name: "Emulator", 35 | dependencies: ["RemoteCore", "Ambassador", "SwiftyJSON"]), 36 | .target( 37 | name: "RemoteCore", 38 | dependencies: ["SwiftyJSON"]), 39 | .testTarget( 40 | name: "RemoteCoreTests", 41 | dependencies: ["RemoteCore"]), 42 | ] 43 | ) 44 | -------------------------------------------------------------------------------- /Sources/Emulator/DefaultRouter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Ambassador 3 | 4 | // Router with exact route matching 5 | // https://github.com/envoy/Ambassador/issues/49#issuecomment-515469435 6 | class DefaultRouter : WebApp { 7 | var routes: [String: WebApp] = [:] 8 | open var notFoundResponse: WebApp = DataResponse( 9 | statusCode: 404, 10 | statusMessage: "Not found" 11 | ) 12 | private let semaphore = DispatchSemaphore(value: 1) 13 | 14 | public init() { 15 | } 16 | 17 | open subscript(path: String) -> WebApp? { 18 | get { 19 | // enter critical section 20 | _ = semaphore.wait(timeout: DispatchTime.distantFuture) 21 | defer { 22 | semaphore.signal() 23 | } 24 | return routes[path] 25 | } 26 | 27 | set { 28 | // enter critical section 29 | _ = semaphore.wait(timeout: DispatchTime.distantFuture) 30 | defer { 31 | semaphore.signal() 32 | } 33 | routes[path] = newValue! 34 | } 35 | } 36 | 37 | open func app( 38 | _ environ: [String: Any], 39 | startResponse: @escaping ((String, [(String, String)]) -> Void), 40 | sendBody: @escaping ((Data) -> Void) 41 | ) { 42 | let path = environ["PATH_INFO"] as! String 43 | 44 | if let (webApp, captures) = matchRoute(to: path) { 45 | var environ = environ 46 | environ["ambassador.router_captures"] = captures 47 | webApp.app(environ, startResponse: startResponse, sendBody: sendBody) 48 | return 49 | } 50 | return notFoundResponse.app(environ, startResponse: startResponse, sendBody: sendBody) 51 | } 52 | 53 | private func matchRoute(to searchPath: String) -> (WebApp, [String])? { 54 | if (routes.keys.contains(searchPath)){ 55 | return (routes[searchPath]!, [searchPath]) 56 | } 57 | 58 | return nil 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/RemoteCore/RemoteAdminControl.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftyJSON 3 | 4 | public class RemoteAdminControl { 5 | private var endpoint = URLComponents() 6 | 7 | public init() { 8 | URLCache.shared.removeAllCachedResponses() 9 | URLCache.shared = URLCache(memoryCapacity: 0, diskCapacity: 0, diskPath: nil) 10 | } 11 | 12 | private func request(method: String, path: String, query: String? = nil, body: String? = nil, completionData: ((Data?) -> ())? = nil) { 13 | var urlComponents = self.endpoint 14 | urlComponents.path = path 15 | urlComponents.query = query 16 | 17 | var request = URLRequest(url: urlComponents.url!) 18 | request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") 19 | request.httpMethod = method 20 | request.httpBody = body?.data(using: .utf8) 21 | 22 | let task = URLSession.shared.dataTask(with: request) { (data, response, error) in 23 | completionData?(data) 24 | }; 25 | 26 | task.resume() 27 | } 28 | 29 | public func setEndpoint(host: String, port: Int) { 30 | self.endpoint.host = host 31 | self.endpoint.port = port 32 | self.endpoint.scheme = "http" 33 | } 34 | 35 | public func clearEndpoint() { 36 | self.endpoint.host = nil 37 | self.endpoint.port = nil 38 | } 39 | 40 | public func getEnabledControlledSourceIds(_ completion: @escaping ([String]) -> ()) { 41 | func completionData(data: Data?) { 42 | var sourceIds = [String]() 43 | 44 | guard let jsonData = data, jsonData.count > 0 else { 45 | completion(sourceIds) 46 | return 47 | } 48 | 49 | let json = JSON(data: jsonData) 50 | 51 | guard let list = json[0]["controlledSources"]["controlledSources"].array else { 52 | completion(sourceIds) 53 | return 54 | } 55 | 56 | for source in list { 57 | if source["enabled"].boolValue, let id = source["sourceId"].string { 58 | sourceIds.append(id) 59 | } 60 | } 61 | completion(sourceIds) 62 | } 63 | 64 | request(method: "GET", path: "/api/getData", query: "path=settings:/beo/sources/controlledSources&roles=value", completionData: completionData) 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /Sources/RemoteCLI/Interactive.swift: -------------------------------------------------------------------------------- 1 | import Darwin 2 | import LineNoise 3 | import RemoteCore 4 | 5 | public class Interactive { 6 | let ln: LineNoise 7 | let tool: CommandLineTool 8 | 9 | public init(_ tool: CommandLineTool) { 10 | self.tool = tool 11 | self.ln = LineNoise() 12 | setupLineNoise() 13 | } 14 | 15 | private func setupLineNoise() { 16 | self.ln.setCompletionCallback { currentBuffer in 17 | let completions = self.tool.commands 18 | return completions.filter { $0.hasPrefix(currentBuffer) } 19 | } 20 | 21 | self.ln.setHintsCallback { currentBuffer in 22 | let hints = self.tool.commands 23 | let filtered = hints.filter { $0.hasPrefix(currentBuffer) } 24 | 25 | if (currentBuffer != "") { 26 | if let hint = filtered.first { 27 | // return only the missing part of the hint 28 | let hintText = String(hint.dropFirst(currentBuffer.count)) 29 | let color = (127, 127, 127) 30 | return (hintText, color) 31 | } 32 | } 33 | 34 | return (nil, nil) 35 | } 36 | 37 | } 38 | 39 | public func run() { 40 | var done = false 41 | while !done { 42 | do { 43 | let input = try self.ln.getLine(prompt: "> ") 44 | print("") 45 | 46 | if input == "exit" || input == "quit" || input == "" { 47 | break 48 | } 49 | 50 | // support arguments ala: selectDevice "Beoplay M5" 51 | let parts = input.split(separator: " ", maxSplits: 1) 52 | var command: String? 53 | var option: String? 54 | 55 | if parts.count > 0 { 56 | command = String(parts[0]) 57 | } 58 | 59 | if parts.count > 1 { 60 | option = String(parts[1]) 61 | if option?.first == "\u{22}" && option?.last == "\u{22}" { 62 | option = String(option!.dropFirst(1).dropLast(1)) 63 | } 64 | } 65 | 66 | _ = self.tool.run(command, option) 67 | self.ln.addHistory(input) 68 | 69 | } catch LinenoiseError.EOF { 70 | done = true 71 | print("") 72 | } catch LinenoiseError.CTRL_C { 73 | done = true 74 | print("") 75 | } catch { 76 | fputs("\(error)\n", stderr) 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/RemoteCore/NotificationSession.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class NotificationSession : NSObject, URLSessionDataDelegate { 4 | private let queue = DispatchQueue.init(label: "beoplayremote-notifications-session") 5 | private let url: URL 6 | private let processor: NotificationProcessor 7 | private var shutdown = false 8 | private var backoff = 1 9 | 10 | private lazy var session: URLSession = { 11 | let configuration = URLSessionConfiguration.default 12 | //configuration.timeoutIntervalForRequest = TimeInterval(3) 13 | //configuration.timeoutIntervalForResource = TimeInterval(5) 14 | return URLSession(configuration: configuration, delegate: self, delegateQueue: nil) 15 | }() 16 | 17 | public enum ConnectionState: Int { 18 | case offline = 1 19 | case connecting = 2 20 | case reconnecting = 3 21 | case disconnecting = 4 22 | case online = 5 23 | } 24 | 25 | public init(url: URL, processor: NotificationProcessor) { 26 | self.url = url 27 | self.processor = processor 28 | } 29 | 30 | public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { 31 | if self.backoff > 5 { 32 | self.processor.update(state: ConnectionState.online, message: "backoff reset from \(self.backoff)s") 33 | self.backoff = 1 34 | } else { 35 | self.processor.update(state: ConnectionState.online) 36 | } 37 | 38 | self.processor.process(data) 39 | } 40 | 41 | public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 42 | self.processor.update(state: ConnectionState.offline, message: error?.localizedDescription) 43 | self.reconnect() 44 | } 45 | 46 | private func reconnect() { 47 | self.queue.async { 48 | if !self.shutdown { 49 | if self.backoff > 5 { 50 | self.processor.update(state: ConnectionState.reconnecting, message: "backoff is \(self.backoff)s") 51 | } 52 | 53 | let ms = UInt32(self.backoff * 1000000) 54 | usleep(ms) 55 | 56 | if self.backoff < 125 { 57 | self.backoff *= 5 58 | } 59 | 60 | if !self.shutdown { 61 | self.start() 62 | } 63 | } 64 | } 65 | } 66 | 67 | public func start() { 68 | self.shutdown = false 69 | self.processor.update(state: ConnectionState.connecting) 70 | self.session.dataTask(with: self.url).resume() 71 | } 72 | 73 | public func stop() { 74 | self.shutdown = true 75 | self.processor.update(state: ConnectionState.disconnecting) 76 | self.session.invalidateAndCancel() 77 | self.processor.update(state: ConnectionState.offline) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/Emulator/AsyncResponseHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Embassy 3 | import RemoteCore 4 | 5 | public class AsyncResponseHandler : NSObject { 6 | let app: AsyncNotificationResponse 7 | let environ: [String: Any] 8 | var sendBody: (Data) -> Void 9 | var loop: EventLoop 10 | let updateInterval = TimeInterval(1.0) 11 | var limit = 0 12 | 13 | private let lock = DispatchSemaphore(value: 1) 14 | private var _counter = 0 15 | 16 | // https://gist.github.com/nestserau/ce8f5e5d3f68781732374f7b1c352a5a 17 | func incrementAndGetCounter() -> Int { 18 | lock.wait() 19 | defer { lock.signal() } 20 | _counter += 1 21 | return _counter 22 | } 23 | 24 | func getCounter() -> Int { 25 | lock.wait() 26 | defer { lock.signal() } 27 | return _counter 28 | } 29 | 30 | // https://stackoverflow.com/a/28016692/936466 31 | static let iso8601: DateFormatter = { 32 | let formatter = DateFormatter() 33 | formatter.calendar = Calendar(identifier: .iso8601) 34 | formatter.locale = Locale(identifier: "en_US_POSIX") 35 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 36 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" 37 | return formatter 38 | }() 39 | 40 | func getIso8601Timestamp() -> String { 41 | AsyncResponseHandler.iso8601.string(from: Date()) 42 | } 43 | 44 | init(app: AsyncNotificationResponse, environ: [String: Any], sendBody: @escaping ((Data) -> Void)) { 45 | self.app = app 46 | self.environ = environ 47 | self.sendBody = sendBody 48 | self.loop = environ["embassy.event_loop"] as! EventLoop 49 | } 50 | 51 | deinit { 52 | app.emulator.removeObserver(observer: self) 53 | } 54 | 55 | func startProgressLoop(limit: Int) { 56 | self.limit = limit 57 | progressLoop() 58 | } 59 | 60 | func progressLoop() { 61 | sendProgress() 62 | 63 | guard getCounter() <= limit else { 64 | NSLog("disconnect observer after \(limit) seconds") 65 | sendBody(Data()) 66 | return 67 | } 68 | 69 | loop.call(withDelay: updateInterval) { 70 | self.progressLoop() 71 | } 72 | } 73 | 74 | func sendProgress() { 75 | sendProgress(state: app.emulator.state.rawValue) 76 | } 77 | 78 | func sendProgress(state: String) { 79 | let obj = ["notification": [ 80 | "type": "PROGRESS_INFORMATION", 81 | "id": app.incrementAndGetCounter(), 82 | "timestamp": getIso8601Timestamp(), 83 | "kind": "playing", 84 | "data": [ 85 | "state": state, 86 | "position": incrementAndGetCounter(), 87 | "totalDuration": limit, 88 | "seekSupported": false, 89 | "playQueueItemId": "plid-000" 90 | ] 91 | ]] 92 | 93 | if let data = try? JSONSerialization.data(withJSONObject: obj) { 94 | sendBody(data) 95 | sendBody(Data("\n\n".utf8)) 96 | } 97 | } 98 | 99 | func sendVolume() { 100 | sendVolume(volume: app.emulator.volume) 101 | } 102 | 103 | func sendVolume(volume: Int) { 104 | let obj = ["notification": [ 105 | "type": "VOLUME", 106 | "id": app.incrementAndGetCounter(), 107 | "timestamp": getIso8601Timestamp(), 108 | "kind": "renderer", 109 | "data": [ 110 | "speaker": [ 111 | "level": volume, 112 | "muted": app.emulator.volMuted, 113 | "range": [ 114 | "minimum": app.emulator.volMin, 115 | "maximum": app.emulator.volMax 116 | ] 117 | ] 118 | ] 119 | ]] 120 | 121 | if let data = try? JSONSerialization.data(withJSONObject: obj) { 122 | sendBody(data) 123 | sendBody(Data("\n\n".utf8)) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # beoplay-cli 2 | 3 | This is an unofficial command line interface (CLI) for macOS to remote control network enabled Beoplay loudspeakers. 4 | 5 | This repository contains: 6 | - [Device Emulator](https://github.com/tlk/beoplay-macos-remote-cli/tree/master/Sources/Emulator): announces itself on the network (zeroconf/bonjour) and supports volume adjustments 7 | - [RemoteCore](https://github.com/tlk/beoplay-macos-remote-cli/tree/master/Sources/RemoteCore): device discovery (zeroconf/bonjour), notification module (event based, auto reconnect) 8 | - [RemoteCLI](https://github.com/tlk/beoplay-macos-remote-cli/tree/master/Sources/RemoteCLI): command line interface using the RemoteCore library. 9 | 10 | The RemoteCore library is also used by https://github.com/tlk/beoplay-macos-remote-gui which supports keyboard shortcuts. 11 | 12 | 13 | ## Usage 14 | 15 | #### Interactive mode with hints and tab-completion 16 |  17 | 18 | #### Non-interactive mode 19 | ``` 20 | $ beoplay-cli discover 21 | + "Beoplay Device" http://BeoplayDevice.local.:8080 22 | 23 | $ export BEOPLAY_NAME="Beoplay Device" 24 | $ beoplay-cli getVolume 25 | 35 26 | $ beoplay-cli setVolume 20 27 | $ beoplay-cli getVolume 28 | 20 29 | $ beoplay-cli pause 30 | $ beoplay-cli play 31 | $ beoplay-cli monitor 32 | RemoteCore.NotificationBridge.DataConnectionNotification(state: RemoteCore.NotificationSession.ConnectionState.connecting, message: nil) 33 | RemoteCore.NotificationBridge.DataConnectionNotification(state: RemoteCore.NotificationSession.ConnectionState.online, message: nil) 34 | RemoteCore.Source(id: "radio:2714.1200304.28096178@products.bang-olufsen.com", type: "TUNEIN", category: "RADIO", friendlyName: "TuneIn", productJid: "2714.1200304.28096178@products.bang-olufsen.com", productFriendlyName: "Beoplay M5 i køkkenet", state: RemoteCore.DeviceState.play) 35 | RemoteCore.NowPlayingRadio(stationId: "s37309", liveDescription: "Higher Love - Kygo & Whitney Houston", name: "96.5 | DR P4 København (Euro Hits)") 36 | RemoteCore.Progress(playQueueItemId: "plid-4342.3", state: RemoteCore.DeviceState.play) 37 | RemoteCore.Volume(volume: 35, muted: false, minimum: 0, maximum: 90) 38 | RemoteCore.Progress(playQueueItemId: "plid-4342.3", state: RemoteCore.DeviceState.play) 39 | RemoteCore.Progress(playQueueItemId: "plid-4342.3", state: RemoteCore.DeviceState.play) 40 | RemoteCore.Progress(playQueueItemId: "plid-4342.3", state: RemoteCore.DeviceState.play) 41 | 42 | RemoteCore.NotificationBridge.DataConnectionNotification(state: RemoteCore.NotificationSession.ConnectionState.disconnecting, message: nil) 43 | RemoteCore.NotificationBridge.DataConnectionNotification(state: RemoteCore.NotificationSession.ConnectionState.offline, message: nil) 44 | $ 45 | $ beoplay-cli monitor volume 46 | RemoteCore.Volume(volume: 23, muted: false, minimum: 0, maximum: 90) 47 | RemoteCore.Volume(volume: 27, muted: false, minimum: 0, maximum: 90) 48 | RemoteCore.Volume(volume: 31, muted: false, minimum: 0, maximum: 90) 49 | RemoteCore.Volume(volume: 35, muted: false, minimum: 0, maximum: 90) 50 | 51 | $ beoplay-cli emulator "Nice Device" 52 | emulating device "Nice Device" on port 80 (stop with ctrl+c) 53 | ^C 54 | $ 55 | ``` 56 | 57 | 58 | ## Installation 59 | 60 | ``` 61 | $ make install 62 | swift build -c release 63 | [5/5] Linking ./.build/x86_64-apple-macosx/release/beoplay-cli 64 | cp .build/release/beoplay-cli /usr/local/bin/beoplay-cli 65 | ``` 66 | 67 | Alternatively, install with Homebrew: 68 | 69 | ``` 70 | brew install tlk/beoplayremote/beoplay-cli 71 | ``` 72 | 73 | 74 | ## Configuration 75 | 76 | The beoplay-cli tool needs to know which device to connect to when issuing commands such as play, pause, etc. 77 | 78 | Beoplay devices on the local network can be discovered in different ways: 79 | - [Discovery.app](https://apps.apple.com/us/app/discovery-dns-sd-browser/id1381004916?mt=12) 80 | - `dns-sd -B _beoremote._tcp.` 81 | - `beoplay-cli discover` 82 | 83 | The device name can be specified via an environment variable: 84 | ``` 85 | export BEOPLAY_NAME="Beoplay Device" 86 | beoplay-cli play 87 | ``` 88 | 89 | Alternatively, host and port can be used: 90 | ``` 91 | export BEOPLAY_HOST=BeoplayDevice.local. 92 | export BEOPLAY_PORT=8080 93 | beoplay-cli play 94 | ``` 95 | 96 | 97 | ## Related Projects 98 | - macOS menu bar, keyboard shortcuts and auto discovery: https://github.com/tlk/beoplay-macos-remote-gui 99 | - Home Assistant plugin: https://github.com/martonborzak/beoplay-custom-component 100 | - Homebridge plugin: https://github.com/connectjunkie/homebridge-beoplay 101 | - Python web server with auto discovery: https://github.com/mtlhd/beowebmote 102 | -------------------------------------------------------------------------------- /Sources/RemoteCore/NotificationBridge.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftyJSON 3 | 4 | extension Notification.Name { 5 | public static let onConnectionChange = Notification.Name("RemoteCore.onConnectionChange") 6 | public static let onVolumeChange = Notification.Name("RemoteCore.onVolumeChange") 7 | public static let onProgress = Notification.Name("RemoteCore.onProgress") 8 | public static let onSourceChange = Notification.Name("RemoteCore.onSourceChange") 9 | public static let onNowPlayingRadio = Notification.Name("RemoteCore.onNowPlayingRadio") 10 | public static let onNowPlayingStoredMusic = Notification.Name("RemoteCore.onNowPlayingStoredMusic") 11 | } 12 | 13 | public enum DeviceState : String { 14 | case idle, preparing, play, pause, unknown 15 | } 16 | 17 | public struct Volume { 18 | public let volume: Int 19 | public let muted: Bool 20 | public let minimum: Int 21 | public let maximum: Int 22 | } 23 | 24 | public struct Progress { 25 | public let playQueueItemId: String 26 | public let state: DeviceState 27 | } 28 | 29 | public struct Source { 30 | public let id: String 31 | public let type: String 32 | public let category: String 33 | public let friendlyName: String 34 | public let productJid: String 35 | public let productFriendlyName: String 36 | public let state: DeviceState 37 | } 38 | 39 | public struct NowPlayingRadio { 40 | public let stationId: String 41 | public let liveDescription: String 42 | public let name: String 43 | } 44 | 45 | public struct NowPlayingStoredMusic { 46 | public let name: String 47 | public let artist: String 48 | public let album: String 49 | } 50 | 51 | public protocol NotificationProcessor { 52 | func process(_ data: Data) 53 | func update(state: NotificationSession.ConnectionState) 54 | func update(state: NotificationSession.ConnectionState, message: String?) 55 | } 56 | 57 | public class NotificationBridge : NotificationProcessor { 58 | public init() {} 59 | 60 | public func process(_ data: Data) { 61 | if let lines = preProcess(data) { 62 | self.bridge(lines) 63 | } 64 | } 65 | 66 | // Data consists of json objects separated with line breaks. 67 | // Some objects are broken into multiple lines, so let's handle that. 68 | private var lastLine = "" 69 | private func preProcess(_ data: Data) -> [JSON]? { 70 | let chunk = String(decoding: data, as: UTF8.self) 71 | let lines = chunk.split { $0.isNewline } 72 | 73 | return lines.compactMap() { subStr in 74 | let line = String(subStr) 75 | 76 | let json = JSON(data: Data(line.utf8)) 77 | if json["notification"]["type"].string != nil { 78 | lastLine = "" 79 | return json 80 | } else if lastLine.isEmpty == false { 81 | let opt = lastLine + line 82 | let json = JSON(data: Data(opt.utf8)) 83 | if json["notification"]["type"].string != nil { 84 | lastLine = "" 85 | return json 86 | } 87 | } 88 | 89 | lastLine = line 90 | return nil 91 | } 92 | } 93 | 94 | private func bridge(_ notifications: [JSON]) { 95 | for json in notifications { 96 | 97 | if json["notification"]["type"].stringValue == "VOLUME" { 98 | let volume = json["notification"]["data"]["speaker"]["level"].intValue 99 | let muted = json["notification"]["data"]["speaker"]["muted"].boolValue 100 | let minimum = json["notification"]["data"]["speaker"]["range"]["minimum"].intValue 101 | let maximum = json["notification"]["data"]["speaker"]["range"]["maximum"].intValue 102 | let data = Volume(volume: volume, muted: muted, minimum: minimum, maximum: maximum) 103 | NotificationCenter.default.post(name: .onVolumeChange, object: self, userInfo: ["data": data]) 104 | 105 | } else if json["notification"]["type"].stringValue == "PROGRESS_INFORMATION" { 106 | let playQueueItemId = json["notification"]["data"]["playQueueItemId"].stringValue 107 | let strState = json["notification"]["data"]["state"].stringValue 108 | let state = DeviceState.init(rawValue: strState) ?? DeviceState.unknown 109 | let data = Progress(playQueueItemId: playQueueItemId, state: state) 110 | NotificationCenter.default.post(name: .onProgress, object: self, userInfo: ["data": data]) 111 | 112 | } else if json["notification"]["type"].stringValue == "SOURCE" { 113 | let id = json["notification"]["data"]["primary"].stringValue 114 | let type = json["notification"]["data"]["primaryExperience"]["source"]["sourceType"]["type"].stringValue 115 | let category = json["notification"]["data"]["primaryExperience"]["source"]["category"].stringValue 116 | let friendlyName = json["notification"]["data"]["primaryExperience"]["source"]["friendlyName"].stringValue 117 | let productJid = json["notification"]["data"]["primaryExperience"]["source"]["product"]["jid"].stringValue 118 | let productFriendlyName = json["notification"]["data"]["primaryExperience"]["source"]["product"]["friendlyName"].stringValue 119 | let strState = json["notification"]["data"]["primaryExperience"]["state"].stringValue 120 | let state = DeviceState.init(rawValue: strState) ?? DeviceState.unknown 121 | let data = Source(id: id, type: type, category: category, friendlyName: friendlyName, productJid: productJid, productFriendlyName: productFriendlyName, state: state) 122 | NotificationCenter.default.post(name: .onSourceChange, object: self, userInfo: ["data": data]) 123 | 124 | } else if json["notification"]["type"].stringValue == "NOW_PLAYING_NET_RADIO" { 125 | let stationId = json["notification"]["data"]["stationId"].stringValue 126 | let liveDescription = json["notification"]["data"]["liveDescription"].stringValue 127 | let name = json["notification"]["data"]["name"].stringValue 128 | let data = NowPlayingRadio(stationId: stationId, liveDescription: liveDescription, name: name) 129 | NotificationCenter.default.post(name: .onNowPlayingRadio, object: self, userInfo: ["data": data]) 130 | 131 | } else if json["notification"]["type"].stringValue == "NOW_PLAYING_STORED_MUSIC" { 132 | let name = json["notification"]["data"]["name"].stringValue 133 | let artist = json["notification"]["data"]["artist"].stringValue 134 | let album = json["notification"]["data"]["album"].stringValue 135 | let data = NowPlayingStoredMusic(name: name, artist: artist, album: album) 136 | NotificationCenter.default.post(name: .onNowPlayingStoredMusic, object: self, userInfo: ["data": data]) 137 | } 138 | } 139 | } 140 | 141 | public struct DataConnectionNotification { 142 | public let state: NotificationSession.ConnectionState 143 | public let message: String? 144 | } 145 | 146 | public func update(state: NotificationSession.ConnectionState) { 147 | update(state: state, message: nil) 148 | } 149 | 150 | private var lastState: NotificationSession.ConnectionState = .offline 151 | public func update(state: NotificationSession.ConnectionState, message: String?) { 152 | if self.lastState != state { 153 | self.lastState = state 154 | let data = DataConnectionNotification(state: state, message: message) 155 | NotificationCenter.default.post(name: .onConnectionChange, object: self, userInfo: ["data": data]) 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Tests/RemoteCoreTests/RemoteCoreTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import class Foundation.Bundle 3 | import RemoteCore 4 | 5 | final class NotificationBridgeTests: XCTestCase { 6 | func testSourceNotification() throws { 7 | let data: Data = 8 | """ 9 | {"notification":{"timestamp":"2019-12-02T22:18:09.440820","type":"SOURCE","kind":"source","data":{"primary":"spotify:1234.1234567.12345678@products.bang-olufsen.com","primaryJid":"1234.1234567.12345678@products.bang-olufsen.com","primaryExperience":{"source":{"id":"spotify:1234.1234567.12345678@products.bang-olufsen.com","friendlyName":"Spotify","sourceType":{"type":"SPOTIFY"},"category":"MUSIC","inUse":true,"profile":"","linkable":false,"recommendedIrMapping":[{"format":0,"unit":0,"command":146},{"format":11,"unit":0,"command":150}],"contentProtection":{"schemeList":["PROPRIETARY"]},"embeddedBinary":{"schemeList":["SPOTIFY_EMBEDDED_SDK"]},"product":{"jid":"1234.1234567.12345678@products.bang-olufsen.com","friendlyName":"Beoplay Device"}},"listener":["1234.1234567.12345678@products.bang-olufsen.com"],"lastUsed":"2019-12-02T22:18:07.692000","state":"play","_capabilities":{"supportedNotifications":[{"type":"SOURCE","kind":"source"},{"type":"SOURCE_EXPERIENCE_CHANGED","kind":"source"},{"type":"PLAY_QUEUE_CHANGED","kind":"playing"},{"type":"NOW_PLAYING_ENDED","kind":"playing"},{"type":"STREAMING_STATUS","kind":"streaming"},{"type":"PROGRESS_INFORMATION","kind":"playing"},{"type":"NOW_PLAYING_STORED_MUSIC","kind":"playing"}]}}}}} 10 | """.data(using: .utf8)! 11 | 12 | expectation(forNotification: Notification.Name.onSourceChange, object: nil) 13 | NotificationBridge().process(data) 14 | waitForExpectations(timeout: 0.1, handler: nil) 15 | } 16 | 17 | func testSourceNotificationWithLinebreaks() throws { 18 | let data: Data = 19 | """ 20 | {"notification":{"timestamp":"2019-12-02T22:18:09.440820","type":"SOURCE","kind":"source","data":{"primary":"spotify:1234.1234567.12345678@products.bang-olufsen.com", 21 | "primaryJid":"1234.1234567.12345678@products.bang-olufsen.com","primaryExperience":{"source":{"id":"spotify:1234.1234567.12345678@products.bang-olufsen.com","friendlyName":"Spotify","sourceType":{"type":"SPOTIFY"},"category":"MUSIC","inUse":true,"profile":"","linkable":false,"recommendedIrMapping":[{"format":0,"unit":0,"command":146},{"format":11,"unit":0,"command":150}],"contentProtection":{"schemeList":["PROPRIETARY"]},"embeddedBinary":{"schemeList":["SPOTIFY_EMBEDDED_SDK"]},"product":{"jid":"1234.1234567.12345678@products.bang-olufsen.com","friendlyName":"Beoplay Device"}},"listener":["1234.1234567.12345678@products.bang-olufsen.com"],"lastUsed":"2019-12-02T22:18:07.692000","state":"play","_capabilities":{"supportedNotifications":[{"type":"SOURCE","kind":"source"},{"type":"SOURCE_EXPERIENCE_CHANGED","kind":"source"},{"type":"PLAY_QUEUE_CHANGED","kind":"playing"},{"type":"NOW_PLAYING_ENDED","kind":"playing"},{"type":"STREAMING_STATUS","kind":"streaming"},{"type":"PROGRESS_INFORMATION","kind":"playing"},{"type":"NOW_PLAYING_STORED_MUSIC","kind":"playing"}]}}}}} 22 | """.data(using: .utf8)! 23 | 24 | expectation(forNotification: Notification.Name.onSourceChange, object: nil) 25 | NotificationBridge().process(data) 26 | waitForExpectations(timeout: 0.1, handler: nil) 27 | } 28 | 29 | func testSourceNotificationWithLinebreakAfterCurly() throws { 30 | let data: Data = 31 | """ 32 | {"notification":{"timestamp":"2019-12-02T22:18:09.440820","type":"SOURCE","kind":"source","data":{"primary":"spotify:1234.1234567.12345678@products.bang-olufsen.com","primaryJid":"1234.1234567.12345678@products.bang-olufsen.com","primaryExperience":{"source":{"id":"spotify:1234.1234567.12345678@products.bang-olufsen.com","friendlyName":"Spotify","sourceType":{"type":"SPOTIFY"},"category":"MUSIC","inUse":true,"profile":"","linkable":false,"recommendedIrMapping":[{"format":0,"unit":0,"command":146},{"format":11,"unit":0,"command":150}],"contentProtection":{"schemeList":["PROPRIETARY"]},"embeddedBinary":{"schemeList":["SPOTIFY_EMBEDDED_SDK"]},"product":{"jid":"1234.1234567.12345678@products.bang-olufsen.com","friendlyName":"Beoplay Device"}},"listener":["1234.1234567.12345678@products.bang-olufsen.com"],"lastUsed":"2019-12-02T22:18:07.692000","state":"play","_capabilities":{"supportedNotifications":[{"type":"SOURCE","kind":"source"},{"type":"SOURCE_EXPERIENCE_CHANGED","kind":"source"},{"type":"PLAY_QUEUE_CHANGED","kind":"playing"},{"type":"NOW_PLAYING_ENDED","kind":"playing"},{"type":"STREAMING_STATUS","kind":"streaming"},{"type":"PROGRESS_INFORMATION","kind":"playing"},{"type":"NOW_PLAYING_STORED_MUSIC","kind":"playing"} 33 | 34 | ]}}}}} 35 | """.data(using: .utf8)! 36 | 37 | expectation(forNotification: Notification.Name.onSourceChange, object: nil) 38 | NotificationBridge().process(data) 39 | waitForExpectations(timeout: 0.1, handler: nil) 40 | } 41 | 42 | func testRadioNotification() throws { 43 | let data: Data = 44 | """ 45 | {"notification":{"timestamp":"2019-12-02T23:35:54.464106","type":"NOW_PLAYING_NET_RADIO","kind":"playing","data":{"name":"DR P4","genre":"","country":"","languages":"","image":[{"url":"http://cdn-profiles.tunein.com/s37309/images/logog.png","size":"large","mediatype":"image/png"}],"liveDescription":"Just an illusion - Imagination","stationId":"s37309","playQueueItemId":"plid-640"}}} 46 | """.data(using: .utf8)! 47 | 48 | expectation(forNotification: Notification.Name.onNowPlayingRadio, object: nil) 49 | NotificationBridge().process(data) 50 | waitForExpectations(timeout: 0.1, handler: nil) 51 | } 52 | 53 | func testStoredMusicNotification() throws { 54 | let data: Data = 55 | """ 56 | {"notification":{"timestamp":"2019-12-02T22:18:09.441606","type":"NOW_PLAYING_STORED_MUSIC","kind":"playing","data":{"name":"Places","duration":214,"trackImage":[{"url":"https://i.scdn.co/image/ab67616d0000b273fb8b2c04222171f2c970d6ac","size":"large","mediatype":"image/jpg"}],"artist":"The Blaze","artistImage":[],"album":"Dancehall","albumImage":[{"url":"https://i.scdn.co/image/ab67616d0000b273fb8b2c04222171f2c970d6ac","size":"large","mediatype":"image/jpg"}],"genre":"","playQueueItemId":"spotify:track:6mW2IiQDrp66AUjCsRu6Kg"}}} 57 | 58 | """.data(using: .utf8)! 59 | 60 | expectation(forNotification: Notification.Name.onNowPlayingStoredMusic, object: nil) 61 | NotificationBridge().process(data) 62 | waitForExpectations(timeout: 0.1, handler: nil) 63 | } 64 | 65 | func testProgressNotification() throws { 66 | let data: Data = 67 | """ 68 | {"notification":{"timestamp":"2019-12-02T22:18:09.441855","type":"PROGRESS_INFORMATION","kind":"playing","data":{"state":"play","position":98,"totalDuration":214,"seekSupported":true,"playQueueItemId":"spotify:track:6mW2IiQDrp66AUjCsRu6Kg"}}} 69 | """.data(using: .utf8)! 70 | 71 | expectation(forNotification: Notification.Name.onProgress, object: nil) 72 | NotificationBridge().process(data) 73 | waitForExpectations(timeout: 0.1, handler: nil) 74 | } 75 | 76 | func testVolumeNotification() throws { 77 | let data: Data = 78 | """ 79 | {"notification":{"timestamp":"2019-12-02T22:18:09.442015","type":"VOLUME","kind":"renderer","data":{"speaker":{"level":9,"muted":false,"range":{"minimum":0,"maximum":90}}}}} 80 | """.data(using: .utf8)! 81 | 82 | expectation(forNotification: Notification.Name.onVolumeChange, object: nil) 83 | NotificationBridge().process(data) 84 | waitForExpectations(timeout: 0.1, handler: nil) 85 | } 86 | 87 | func testConnectionNotification() throws { 88 | expectation(forNotification: Notification.Name.onConnectionChange, object: nil) 89 | NotificationBridge().update(state: NotificationSession.ConnectionState.connecting) 90 | waitForExpectations(timeout: 0.1, handler: nil) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/RemoteCore/RemoteControl.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftyJSON 3 | 4 | public class RemoteControl { 5 | private var endpoint = URLComponents() 6 | private var notificationSession: NotificationSession? 7 | private var remoteAdmin = RemoteAdminControl() 8 | private let browser = BeoplayBrowser() 9 | 10 | public init() { 11 | URLCache.shared.removeAllCachedResponses() 12 | URLCache.shared = URLCache(memoryCapacity: 0, diskCapacity: 0, diskPath: nil) 13 | } 14 | 15 | private func request(method: String, path: String, query: String? = nil, body: String? = nil, _ completion: (() -> ())? = nil) { 16 | request(method: method, path: path, query: query, body: body) { _ in 17 | completion?() 18 | } 19 | } 20 | 21 | private func request(method: String, path: String, query: String? = nil, body: String? = nil, completionData: ((Data?) -> ())? = nil) { 22 | var urlComponents = self.endpoint 23 | urlComponents.path = path 24 | urlComponents.query = query 25 | 26 | var request = URLRequest(url: urlComponents.url!) 27 | request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") 28 | request.httpMethod = method 29 | request.httpBody = body?.data(using: .utf8) 30 | 31 | let task = URLSession.shared.dataTask(with: request) { (data, response, error) in 32 | completionData?(data) 33 | }; 34 | 35 | task.resume() 36 | } 37 | 38 | public func startDiscovery(delegate: NetServiceBrowserDelegate, withTimeout: TimeInterval? = nil, next: (() -> ())? = nil) { 39 | browser.searchForDevices(delegate: delegate, withTimeout: withTimeout, next: next) 40 | } 41 | 42 | public func stopDiscovery() { 43 | browser.stop() 44 | } 45 | 46 | public func setEndpoint(host: String, port: Int, adminPort: Int = 80) { 47 | self.endpoint.host = host 48 | self.endpoint.port = port 49 | self.endpoint.scheme = "http" 50 | self.remoteAdmin.setEndpoint(host: host, port: adminPort) 51 | } 52 | 53 | public func clearEndpoint() { 54 | self.endpoint.host = nil 55 | self.endpoint.port = nil 56 | self.remoteAdmin.clearEndpoint() 57 | } 58 | 59 | public func hasEndpoint() -> Bool { 60 | return self.endpoint.host != nil 61 | } 62 | 63 | public func getSources(_ completion: @escaping ([BeoplaySource]) -> ()) { 64 | func completionData(data: Data?) { 65 | var sources = [BeoplaySource]() 66 | 67 | guard let jsonData = data, jsonData.count > 0 else { 68 | completion(sources) 69 | return 70 | } 71 | 72 | let json = JSON(data: jsonData) 73 | 74 | guard let list = json["sources"].array else { 75 | completion(sources) 76 | return 77 | } 78 | 79 | for element in list { 80 | let source = BeoplaySource( 81 | id: element[0].stringValue, 82 | sourceType: element[1]["sourceType"]["type"].stringValue, 83 | category: element[1]["category"].stringValue, 84 | friendlyName: element[1]["friendlyName"].stringValue, 85 | borrowed: element[1]["borrowed"].boolValue, 86 | productJid: element[1]["product"]["jid"].stringValue, 87 | productFriendlyName: element[1]["product"]["friendlyName"].stringValue 88 | ) 89 | sources.append(source) 90 | } 91 | completion(sources) 92 | } 93 | 94 | request(method: "GET", path: "/BeoZone/Zone/Sources", completionData: completionData) 95 | } 96 | 97 | public func getEnabledSources(_ completion: @escaping ([BeoplaySource]) -> ()) { 98 | self.remoteAdmin.getEnabledControlledSourceIds { (enabledSourceIds: [String]) -> () in 99 | self.getSources { (sources: [BeoplaySource]) -> () in 100 | var result = [BeoplaySource]() 101 | if enabledSourceIds.isEmpty { 102 | // fallback fx for Beosound Moment 103 | result = sources 104 | } else { 105 | for id in enabledSourceIds { 106 | let isBorrowed = id.contains(":") 107 | for source in sources { 108 | let sourceId = isBorrowed ? id : "\(id):\(source.productJid)" 109 | if sourceId == source.id && isBorrowed == source.borrowed { 110 | result.append(source) 111 | } 112 | } 113 | } 114 | } 115 | 116 | completion(result) 117 | } 118 | } 119 | } 120 | 121 | public func setSource(id: String, _ completion: @escaping () -> () = {}) { 122 | request(method: "POST", path: "/BeoZone/Zone/ActiveSources", body: "{\"primaryExperience\":{\"source\":{\"id\":\"\(id)\"}}}", completion) 123 | } 124 | 125 | public func getTuneInFavorites(_ completion: @escaping (_ favorites: [(String, String)]) -> ()) { 126 | var favorites = [(String, String)]() 127 | request(method: "GET", path: "/BeoContent/radio/netRadioProfile/favoriteList/id=f1/favoriteListStation") { data in 128 | 129 | guard let jsonData = data, jsonData.count > 0 else { 130 | completion(favorites) 131 | return 132 | } 133 | 134 | let json = JSON(data: jsonData) 135 | 136 | guard let list = json["favoriteListStationList"]["favoriteListStation"].array else { 137 | completion(favorites) 138 | return 139 | } 140 | 141 | for element in list { 142 | let station = element["station"] 143 | favorites.append((station["tuneIn"]["stationId"].stringValue, station["name"].stringValue)) 144 | } 145 | completion(favorites) 146 | } 147 | } 148 | 149 | public func tuneIn(stations: [(String, String)], _ completion: @escaping () -> () = {}) { 150 | let items = stations.map { id, name in 151 | [ 152 | "behaviour": "planned", 153 | "id": id, 154 | "station": [ 155 | "id": id, 156 | "image": [ 157 | [ 158 | "mediatype": "image/jpg", 159 | "size": "medium", 160 | "url": "" 161 | ] 162 | ], 163 | "name": name, 164 | "tuneIn": [ 165 | "location": "", 166 | "stationId": id 167 | ] 168 | ] 169 | ] 170 | } 171 | let payload = JSON(["playQueueItem": items]).rawString()! 172 | 173 | request(method: "DELETE", path: "/BeoZone/Zone/PlayQueue/") { 174 | self.request(method: "POST", path: "/BeoZone/Zone/PlayQueue/", query: "instantplay", body: payload, completion) 175 | } 176 | } 177 | 178 | public func join(_ completion: @escaping () -> () = {}) { 179 | request(method: "POST", path: "/BeoZone/Zone/Device/OneWayJoin", completion); 180 | } 181 | 182 | public func leave(_ completion: @escaping () -> () = {}) { 183 | request(method: "DELETE", path: "/BeoZone/Zone/ActiveSources/primaryExperience", completion); 184 | } 185 | 186 | public func play(_ completion: @escaping () -> () = {}) { 187 | request(method: "POST", path: "/BeoZone/Zone/Stream/Play") { 188 | self.request(method: "POST", path: "/BeoZone/Zone/Stream/Play/Release", completion) 189 | } 190 | } 191 | 192 | public func pause(_ completion: @escaping () -> () = {}) { 193 | request(method: "POST", path: "/BeoZone/Zone/Stream/Pause") { 194 | self.request(method: "POST", path: "/BeoZone/Zone/Stream/Pause/Release", completion) 195 | } 196 | } 197 | 198 | public func stop(_ completion: @escaping () -> () = {}) { 199 | request(method: "POST", path: "/BeoZone/Zone/Stream/Stop") { 200 | self.request(method: "POST", path: "/BeoZone/Zone/Stream/Stop/Release", completion) 201 | } 202 | } 203 | 204 | public func next(_ completion: @escaping () -> () = {}) { 205 | request(method: "POST", path: "/BeoZone/Zone/Stream/Forward") { 206 | self.request(method: "POST", path: "/BeoZone/Zone/Stream/Forward/Release", completion) 207 | } 208 | } 209 | 210 | public func back(_ completion: @escaping () -> () = {}) { 211 | request(method: "POST", path: "/BeoZone/Zone/Stream/Backward") { 212 | self.request(method: "POST", path: "/BeoZone/Zone/Stream/Backward/Release", completion) 213 | } 214 | } 215 | 216 | public func getVolume(_ completion: @escaping (Int?) -> ()) { 217 | func getVolumeFromJSON(_ data: Data?) -> Int? { 218 | guard let jsonData = data, jsonData.count > 0 else { 219 | return nil 220 | } 221 | 222 | let json = JSON(data: jsonData) 223 | 224 | guard let volume = json["speaker"]["level"].int else { 225 | return nil 226 | } 227 | 228 | return volume 229 | } 230 | 231 | func completionData(data: Data?) { 232 | let vol = getVolumeFromJSON(data) 233 | completion(vol) 234 | } 235 | 236 | request(method: "GET", path: "/BeoZone/Zone/Sound/Volume/Speaker/", completionData: completionData) 237 | } 238 | 239 | public func setVolume(volume: Int, _ completion: @escaping () -> () = {}) { 240 | var vol = max(0, volume) 241 | vol = min(100, vol) 242 | request(method: "PUT", path: "/BeoZone/Zone/Sound/Volume/Speaker/Level", body: "{\"level\":\(vol)}", completion) 243 | } 244 | 245 | public func adjustVolume(delta: Int, _ completion: @escaping () -> () = {}) { 246 | self.getVolume { volume in 247 | guard let vol = volume else { 248 | return 249 | } 250 | self.setVolume(volume: vol + delta, completion) 251 | } 252 | } 253 | 254 | public func mute(_ completion: @escaping () -> () = {}) { 255 | request(method: "PUT", path: "/BeoZone/Zone/Sound/Volume/Speaker/Muted", body: "{\"muted\":true}", completion) 256 | } 257 | 258 | public func unmute(_ completion: @escaping () -> () = {}) { 259 | request(method: "PUT", path: "/BeoZone/Zone/Sound/Volume/Speaker/Muted", body: "{\"muted\":false}", completion) 260 | } 261 | 262 | public func startNotifications() { 263 | var urlComponents = self.endpoint 264 | urlComponents.path = "/BeoNotify/Notifications" 265 | 266 | self.notificationSession = NotificationSession(url: urlComponents.url!, processor: NotificationBridge()) 267 | self.notificationSession?.start() 268 | } 269 | 270 | public func stopNotifications() { 271 | self.notificationSession?.stop() 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /Sources/RemoteCLI/CommandLineTool.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RemoteCore 3 | import Emulator 4 | 5 | #if os(Linux) 6 | import Glibc 7 | #else 8 | import Darwin.C 9 | #endif 10 | 11 | public class CommandLineTool { 12 | let defaultTimeout = 3.0 13 | let remoteControl = RemoteControl() 14 | let sema = DispatchSemaphore(value: 0) 15 | 16 | public func enablePiping() { 17 | // Disable buffering instead of doing manual flush 18 | // https://stackoverflow.com/a/28180452/936466 19 | setbuf(__stdoutp, nil); 20 | setbuf(__stderrp, nil); 21 | } 22 | 23 | private func block() { 24 | sema.wait() 25 | } 26 | 27 | private func unblock() { 28 | sema.signal(); 29 | } 30 | 31 | public let commands = [ 32 | "discover", 33 | "selectDevice ", 34 | "getSources", 35 | "getEnabledSources", 36 | "setSource ", 37 | "getTuneInFavorites", 38 | "tuneIn ", 39 | "join", 40 | "leave", 41 | "play", 42 | "pause", 43 | "stop", 44 | "next", 45 | "back", 46 | "getVolume", 47 | "setVolume ", 48 | "adjustVolume ", 49 | "mute", 50 | "unmute", 51 | "monitor ", 52 | "emulator ", 53 | "help", 54 | ] 55 | 56 | public let commandsWithoutEndpoint = [ 57 | "discover", 58 | "selectDevice", 59 | "emulator" 60 | ] 61 | 62 | public func endpointIsRequired(_ command: String?) -> Bool { 63 | command != nil && 64 | commands.map { $0.trimmingCharacters(in: .whitespaces) } 65 | .filter { !commandsWithoutEndpoint.contains($0) } 66 | .contains(command!) 67 | } 68 | 69 | public func setEndpoint(host: String, port: Int) { 70 | print("device endpoint set to http://\(host):\(port)") 71 | remoteControl.setEndpoint(host: host, port: port) 72 | } 73 | 74 | public func selectDevice(_ name: String) { 75 | var found = false 76 | 77 | func handler(device: NetService) { 78 | guard found == false else { 79 | return 80 | } 81 | 82 | if device.name == name { 83 | found = true 84 | remoteControl.setEndpoint(host: device.hostName!, port: device.port) 85 | remoteControl.stopDiscovery() 86 | } 87 | } 88 | 89 | let delegate = SerializedDeviceLocator(didFind: handler, didStop: unblock) 90 | remoteControl.startDiscovery(delegate: delegate, withTimeout: defaultTimeout) 91 | block() 92 | } 93 | 94 | func sourcesHandler(sources: [BeoplaySource]) { 95 | if sources.isEmpty { 96 | fputs("failed to get sources\n", stderr) 97 | } else { 98 | dump(sources) 99 | } 100 | unblock() 101 | } 102 | 103 | private func volumeHandlerUnblock(volume: Int?) { 104 | volumeHandler(volume: volume) 105 | unblock() 106 | } 107 | 108 | private func volumeHandler(volume: Int?) { 109 | if volume == nil { 110 | fputs("failed to get volume level\n", stderr) 111 | } else { 112 | print(volume!) 113 | } 114 | } 115 | 116 | public func run(_ command: String?, _ option: String?) -> Int32 { 117 | guard self.remoteControl.hasEndpoint() || !endpointIsRequired(command) else { 118 | fputs("failed to configure device endpoint\n", stderr) 119 | return 1 120 | } 121 | 122 | switch command { 123 | case "discover": 124 | if option == "notimeout" { 125 | self.remoteControl.startDiscovery(delegate: ConsoleDeviceLocator()) 126 | _ = readLine() 127 | self.remoteControl.stopDiscovery() 128 | } else { 129 | self.remoteControl.startDiscovery(delegate: ConsoleDeviceLocator(), withTimeout: defaultTimeout, next: unblock) 130 | block() 131 | } 132 | case "selectDevice": 133 | if option != nil { 134 | selectDevice(option!) 135 | if self.remoteControl.hasEndpoint() { 136 | print("device endpoint successfully located") 137 | } else { 138 | fputs("failed to locate device\n", stderr) 139 | return 1 140 | } 141 | } else { 142 | fputs(" example: selectDevice \"Beoplay M5\"\n", stderr) 143 | return 1 144 | } 145 | case "getSources": 146 | self.remoteControl.getSources(sourcesHandler) 147 | block() 148 | case "getEnabledSources": 149 | self.remoteControl.getEnabledSources(sourcesHandler) 150 | block() 151 | case "setSource": 152 | if let _ = option?.range(of: #"^\w+:.+"#, options: .regularExpression) { 153 | self.remoteControl.setSource(id: option!, unblock) 154 | block() 155 | } else { 156 | fputs(" example: setSource spotify:2714.1200304.28096178@products.bang-olufsen.com\n", stderr) 157 | return 1 158 | } 159 | case "join": 160 | self.remoteControl.join(unblock) 161 | block() 162 | case "leave": 163 | self.remoteControl.leave(unblock) 164 | block() 165 | case "play": 166 | self.remoteControl.play(unblock) 167 | block() 168 | case "pause": 169 | self.remoteControl.pause(unblock) 170 | block() 171 | case "stop": 172 | self.remoteControl.stop(unblock) 173 | block() 174 | case "next": 175 | self.remoteControl.next(unblock) 176 | block() 177 | case "back": 178 | self.remoteControl.back(unblock) 179 | block() 180 | case "getVolume": 181 | self.remoteControl.getVolume(volumeHandlerUnblock) 182 | block() 183 | case "setVolume": 184 | if let opt = option, let vol = Int(opt) { 185 | self.remoteControl.setVolume(volume: vol, unblock) 186 | block() 187 | } else { 188 | fputs(" example: setVolume 20\n", stderr) 189 | return 1 190 | } 191 | case "adjustVolume": 192 | if let opt = option, let delta = Int(opt) { 193 | self.remoteControl.adjustVolume(delta: delta, unblock) 194 | block() 195 | } else { 196 | fputs(" example: adjustVolume -5\n", stderr) 197 | return 1 198 | } 199 | case "mute": 200 | self.remoteControl.mute(unblock) 201 | block() 202 | case "unmute": 203 | self.remoteControl.unmute(unblock) 204 | block() 205 | case "monitor": 206 | let map = [ 207 | "connection" : Notification.Name.onConnectionChange, 208 | "volume": Notification.Name.onVolumeChange, 209 | "progress": Notification.Name.onProgress, 210 | "source": Notification.Name.onSourceChange, 211 | "radio": Notification.Name.onNowPlayingRadio, 212 | "storedmusic": Notification.Name.onNowPlayingStoredMusic 213 | ] 214 | 215 | var notificationNames = [Notification.Name]() 216 | if let opts = option?.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: ",") { 217 | notificationNames = opts.compactMap { map[String($0)] } 218 | } else if option != nil, let opt = map[option!] { 219 | notificationNames.append(opt) 220 | } else { 221 | // default to all 222 | notificationNames = Array(map.values) 223 | } 224 | 225 | if notificationNames.isEmpty { 226 | fputs(" example: monitor connection,volume,progress,source,radio,storedmusic\n", stderr) 227 | return 1 228 | } 229 | 230 | var observers = [NSObjectProtocol]() 231 | for notificationName in notificationNames { 232 | let observer = NotificationCenter.default.addObserver(forName: notificationName, object: nil, queue: nil) { (notification: Notification) -> Void in 233 | if let data = notification.userInfo?["data"] { 234 | print("\(data as AnyObject)") 235 | } 236 | } 237 | observers.append(observer) 238 | } 239 | self.remoteControl.startNotifications() 240 | 241 | _ = readLine() 242 | 243 | self.remoteControl.stopNotifications() 244 | for observer in observers { 245 | NotificationCenter.default.removeObserver(observer) 246 | } 247 | case "getTuneInFavorites": 248 | self.remoteControl.getTuneInFavorites { (favorites) in 249 | for station in favorites { 250 | print("\(station.0)\t\(station.1)") 251 | } 252 | self.unblock() 253 | } 254 | block() 255 | case "tuneIn": 256 | func map(_ input: String?) -> (String,String)? { 257 | input?.range(of: #"^s[0-9]+$"#, options: .regularExpression) != nil 258 | ? (input!,input!) 259 | : nil 260 | } 261 | var stations = [(String,String)]() 262 | if let matches = option?.contains(","), matches == true { 263 | let result = option?.split(separator: ",").compactMap { 264 | id in map(String(id)) 265 | } 266 | if result != nil { 267 | stations = result! 268 | } 269 | } else if let station = map(option) { 270 | stations.append(station) 271 | } 272 | 273 | if stations.isEmpty == false { 274 | self.remoteControl.tuneIn(stations: stations, unblock) 275 | block() 276 | } else { 277 | fputs(" example: tuneIn s24861,s37309,s69060,s45455,s69056\n", stderr) 278 | return 1 279 | } 280 | case "emulator": 281 | let name = option ?? "Beoplay Emulated Device" 282 | var port = 80 283 | if let strPort = ProcessInfo.processInfo.environment["BEOPLAY_PORT"], let p = Int(strPort) { 284 | port = p 285 | } 286 | print("emulating device \"\(name)\" on port \(port) (stop with ctrl+c)") 287 | let emulator = DeviceEmulator(port: port) 288 | emulator.run(name: name) 289 | default: 290 | let pretty = commands.map { cmd in 291 | cmd.last == " " 292 | ? "\(cmd)[option]" 293 | : cmd 294 | } 295 | fputs(" available commands: \(pretty)\n", stderr) 296 | return 1 297 | } 298 | 299 | return 0 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /Sources/Emulator/DeviceEmulator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Embassy 3 | import Ambassador 4 | import RemoteCore 5 | 6 | public class DeviceEmulator { 7 | let debug = false 8 | 9 | private let _queue = DispatchQueue(label: "device-emulator") 10 | private var _observers = [AsyncResponseHandler]() 11 | private var _volume = 10 12 | private var _state = RemoteCore.DeviceState.unknown 13 | 14 | func addObserver(observer: AsyncResponseHandler) { 15 | _queue.sync { 16 | NSLog("addObserver") 17 | _observers.append(observer) 18 | } 19 | } 20 | 21 | func removeObserver(observer: AsyncResponseHandler) { 22 | _queue.sync { 23 | NSLog("removeObserver") 24 | if let index = _observers.firstIndex(of: observer) { 25 | _observers.remove(at: index) 26 | } 27 | } 28 | } 29 | 30 | public var volume: Int { 31 | get { 32 | return _queue.sync { return _volume } 33 | } 34 | set { 35 | _queue.sync { 36 | NSLog("setVolume: \(newValue)") 37 | _volume = newValue 38 | for observer in _observers { 39 | observer.sendVolume(volume: _volume) 40 | } 41 | } 42 | } 43 | } 44 | 45 | public var state: RemoteCore.DeviceState { 46 | get { 47 | return _queue.sync { return _state } 48 | } 49 | set { 50 | _queue.sync { 51 | NSLog("setState: \(newValue)") 52 | _state = newValue 53 | for observer in _observers { 54 | observer.sendProgress(state: _state.rawValue) 55 | } 56 | } 57 | } 58 | } 59 | 60 | let volMin = 0 61 | let volMax = 90 62 | let volMuted = false 63 | 64 | let router = DefaultRouter() 65 | var ns: NetService? 66 | var server: DefaultHTTPServer? 67 | var port: Int 68 | 69 | public init(port: Int) { 70 | self.port = port 71 | } 72 | 73 | public func run(name: String) { 74 | ns = NetService(domain: "local.", type: "_beoremote._tcp.", name: name, port: Int32(port)) 75 | ns?.publish() 76 | 77 | if self.ns?.name == "NonRespondingDevice" { 78 | RunLoop.current.run() 79 | // never returns 80 | } 81 | 82 | if let loop = try? SelectorEventLoop(selector: try! KqueueSelector()) { 83 | server = DefaultHTTPServer(eventLoop: loop, interface: "localhost", port: port, app: router.app) 84 | 85 | if debug { 86 | server?.logger.add(handler: PrintLogHandler()) 87 | } 88 | 89 | addRoutes() 90 | try! server?.start() 91 | loop.runForever() 92 | } 93 | } 94 | 95 | public func stop() { 96 | server?.stopAndWait() 97 | ns?.stop() 98 | } 99 | 100 | deinit { 101 | stop() 102 | } 103 | } 104 | 105 | extension DeviceEmulator { 106 | func getName() -> String { 107 | return self.ns?.name ?? "$no-name$" 108 | } 109 | 110 | func addRoutes(getDataApiEnabled : Bool = true) { 111 | // Play, original port: 8080 112 | router["/BeoZone/Zone/Stream/Play"] = JSONResponse() { environ -> Any in 113 | self.state = RemoteCore.DeviceState.play 114 | return [] 115 | } 116 | 117 | // Pause, original port: 8080 118 | router["/BeoZone/Zone/Stream/Pause"] = JSONResponse() { environ -> Any in 119 | self.state = RemoteCore.DeviceState.pause 120 | return [] 121 | } 122 | 123 | // Stop, original port: 8080 124 | router["/BeoZone/Zone/Stream/Stop"] = JSONResponse() { environ -> Any in 125 | self.state = RemoteCore.DeviceState.unknown 126 | return [] 127 | } 128 | 129 | // Get volume, original port: 8080 130 | router["/BeoZone/Zone/Sound/Volume/Speaker/"] = DelayResponse(JSONResponse(handler: { _ -> Any in 131 | return ["speaker": ["level": self.volume]] 132 | })) 133 | 134 | // Set volume, original port: 8080 135 | router["/BeoZone/Zone/Sound/Volume/Speaker/Level"] = JSONResponse() { (environ, sendJSON) in 136 | let input = environ["swsgi.input"] as! SWSGIInput 137 | 138 | guard environ["HTTP_CONTENT_LENGTH"] != nil else { 139 | // handle error 140 | sendJSON([]) 141 | return 142 | } 143 | 144 | JSONReader.read(input) { json in 145 | if let dict = json as? [String: Any], let level = dict["level"] as? Int { 146 | self.volume = level 147 | } 148 | sendJSON([]) 149 | } 150 | } 151 | 152 | // Notifications, original port: 8080 153 | // HTTP Long Polling, see https://en.wikipedia.org/wiki/Push_technology#Long_polling 154 | router["/BeoNotify/Notifications"] = AsyncNotificationResponse(emulator: self) 155 | 156 | // Get sources, original port: 8080 157 | router["/BeoZone/Zone/Sources"] = DelayResponse(JSONResponse(handler: { _ -> Any in 158 | return ["sources": [[ 159 | "radio:1234.1234567.12345678@products.bang-olufsen.com", 160 | [ 161 | "id": "radio:1234.1234567.12345678@products.bang-olufsen.com", 162 | "jid": "1234.1234567.12345678@products.bang-olufsen.com", 163 | "sourceType": ["type": "TUNEIN"], 164 | "category": "RADIO", 165 | "friendlyName": "TuneIn", 166 | "borrowed": false, 167 | "product": [ 168 | "jid": "1234.1234567.12345678@products.bang-olufsen.com", 169 | "friendlyName": self.getName() 170 | ] 171 | ] 172 | ], 173 | [ 174 | "linein:1234.1234567.12345678@products.bang-olufsen.com", 175 | [ 176 | "id": "linein:1234.1234567.12345678@products.bang-olufsen.com", 177 | "jid": "1234.1234567.12345678@products.bang-olufsen.com", 178 | "sourceType": ["type": "LINE IN"], 179 | "category": "MUSIC", 180 | "friendlyName": "Line-In", 181 | "borrowed": false, 182 | "product": [ 183 | "jid": "1234.1234567.12345678@products.bang-olufsen.com", 184 | "friendlyName": self.getName() 185 | ] 186 | ] 187 | ], 188 | [ 189 | "bluetooth:1234.1234567.12345678@products.bang-olufsen.com", 190 | [ 191 | "id": "bluetooth:1234.1234567.12345678@products.bang-olufsen.com", 192 | "jid": "1234.1234567.12345678@products.bang-olufsen.com", 193 | "sourceType": ["type": "BLUETOOTH"], 194 | "category": "MUSIC", 195 | "friendlyName": "Bluetooth", 196 | "borrowed": false, 197 | "product": [ 198 | "jid": "1234.1234567.12345678@products.bang-olufsen.com", 199 | "friendlyName": self.getName() 200 | ] 201 | ] 202 | ], 203 | [ 204 | "alarm:1234.1234567.12345678@products.bang-olufsen.com", 205 | [ 206 | "id": "alarm:1234.1234567.12345678@products.bang-olufsen.com", 207 | "jid": "1234.1234567.12345678@products.bang-olufsen.com", 208 | "sourceType": ["type": "ALARM"], 209 | "category": "ALARM", 210 | "friendlyName": "Alarm", 211 | "borrowed": false, 212 | "product": [ 213 | "jid": "1234.1234567.12345678@products.bang-olufsen.com", 214 | "friendlyName": self.getName() 215 | ] 216 | ] 217 | ], 218 | [ 219 | "spotify:9999.1234567.12345678@products.bang-olufsen.com", 220 | [ 221 | "id": "spotify:9999.1234567.12345678@products.bang-olufsen.com", 222 | "jid": "9999.1234567.12345678@products.bang-olufsen.com", 223 | "sourceType": ["type": "SPOTIFY"], 224 | "category": "MUSIC", 225 | "friendlyName": "Spotify", 226 | "borrowed": true, 227 | "product": [ 228 | "jid": "9999.1234567.12345678@products.bang-olufsen.com", 229 | "friendlyName": "Living Room" 230 | ] 231 | ] 232 | ]]] 233 | })) 234 | 235 | // Get controlled sources, original port: 80 <-- heads up 236 | if (getDataApiEnabled) { 237 | router["/api/getData"] = DelayResponse(JSONResponse(handler: { _ -> Any in 238 | return [["type": "controlledSources", "controlledSources": [ 239 | "controlledSources": [ 240 | [ 241 | "deviceId": "", 242 | "enabled": false, 243 | "sourceId": "linein", 244 | "enabledExternal": true 245 | ], 246 | [ 247 | "deviceId": "", 248 | "enabled": true, 249 | "sourceId": "radio", 250 | "enabledExternal": true 251 | ], 252 | [ 253 | "deviceId": "", 254 | "enabled": false, 255 | "sourceId": "bluetooth", 256 | "enabledExternal": true 257 | ], 258 | [ 259 | "deviceId": "9999.1234567.12345678@products.bang-olufsen.com", 260 | "enabled": true, 261 | "sourceId": "spotify:9999.1234567.12345678@products.bang-olufsen.com", 262 | "enabledExternal": false 263 | ] 264 | ] 265 | ]]] 266 | })) 267 | 268 | } else { 269 | 270 | router["/api/getData"] = DataResponse(statusCode: 500, contentType: "application/json; charset=UTF-8") { environ -> Data in 271 | return Data("{\"error\":{\"message\":\"Error: path not whitelisted!\",\"name\":\"error\"}}".utf8) 272 | } 273 | } 274 | 275 | // Get TuneIn favorite stations, original port: 8080 276 | router["/BeoContent/radio/netRadioProfile/favoriteList/id=f1/favoriteListStation"] = DelayResponse(JSONResponse(handler: { _ -> Any in 277 | return [ 278 | "favoriteListStationList": [ 279 | "offset": 0, 280 | "count": 3, 281 | "total": 3, 282 | "favoriteListStation": [ 283 | [ 284 | "id": "id%3df1_0", 285 | "number": 1, 286 | "station": [ 287 | "id": "id%3df1_0", 288 | "name": "90.8 | DR P1", 289 | "tuneIn": [ 290 | "stationId": "s24860", 291 | "location": "" 292 | ], 293 | "image": [ 294 | [ 295 | "url": "http://cdn-profiles.tunein.com/s24860/images/logog.png", 296 | "size": "large", 297 | "mediatype": "image/png" 298 | ] 299 | ] 300 | ], 301 | "_links": [ 302 | "self": [ 303 | "href": "./id%3df1_0" 304 | ] 305 | ] 306 | ], 307 | [ 308 | "id": "id%3df1_1", 309 | "number": 2, 310 | "station": [ 311 | "id": "id%3df1_1", 312 | "name": "DR P2 Klassisk (Classical Music)", 313 | "tuneIn": [ 314 | "stationId": "s37197", 315 | "location": "" 316 | ], 317 | "image": [ 318 | [ 319 | "url": "http://cdn-profiles.tunein.com/s37197/images/logog.png", 320 | "size": "large", 321 | "mediatype": "image/png" 322 | ] 323 | ] 324 | ], 325 | "_links": [ 326 | "self": [ 327 | "href": "./id%3df1_1" 328 | ] 329 | ] 330 | ], 331 | [ 332 | "id": "id%3df1_2", 333 | "number": 3, 334 | "station": [ 335 | "id": "id%3df1_2", 336 | "name": "93.9 | DR P3 (Euro Hits)", 337 | "tuneIn": [ 338 | "stationId": "s24861", 339 | "location": "" 340 | ], 341 | "image": [ 342 | [ 343 | "url": "http://cdn-profiles.tunein.com/s24861/images/logog.png", 344 | "size": "large", 345 | "mediatype": "image/png" 346 | ] 347 | ] 348 | ], 349 | "_links": [ 350 | "self": [ 351 | "href": "./id%3df1_2" 352 | ] 353 | ] 354 | ] 355 | ] 356 | ] 357 | ] 358 | })) 359 | 360 | router["/hello-world"] = DataResponse(statusCode: 200, contentType: "text/html; charset=UTF-8") { environ -> Data in 361 | return Data("