diff --git a/.gitignore b/.gitignore index 0ab6db5..afb6bbb 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ DerivedData *.ipa *.xcuserstate .idea/ +.DS_Store # Configuration Tests/Secrets.plist diff --git a/LIFXHTTPKit.podspec b/LIFXHTTPKit.podspec new file mode 100644 index 0000000..20bad9c --- /dev/null +++ b/LIFXHTTPKit.podspec @@ -0,0 +1,29 @@ +# +# Be sure to run `pod spec lint LIFXHTTPKit.podspec' to ensure this is a +# valid spec and to remove all comments including this before submitting the spec. +# +# To learn more about Podspec attributes see http://docs.cocoapods.org/specification.html +# To see working Podspecs in the CocoaPods repo see https://github.com/CocoaPods/Specs/ +# + +Pod::Spec.new do |s| + + s.name = "LIFXHTTPKit" + s.version = "3.0.1" + s.summary = "A framework for interacting with the LIFX HTTP API that has no external dependencies. Suitable for use inside extensions." + + s.license = { :type => 'MIT', :file => 'LICENSE.txt' } + s.homepage = "https://github.com/tatey/LIFXHTTPKit" + s.author = { "Alex Stonehouse" => "alexander@lifx.co" } + s.source = { :git => "https://github.com/LIFX/LIFXHTTPKit.git", :tag => "#{s.version}" } + + # Version + s.platform = :ios + s.swift_version = "4.0" + s.ios.deployment_target = "8.2" + s.osx.deployment_target = "10.10" + s.watchos.deployment_target = "2.0" + + s.source_files = "Source/**/*" + +end diff --git a/LIFXHTTPKit.xcodeproj/project.pbxproj b/LIFXHTTPKit.xcodeproj/project.pbxproj index 77b5cfb..a3da13b 100644 --- a/LIFXHTTPKit.xcodeproj/project.pbxproj +++ b/LIFXHTTPKit.xcodeproj/project.pbxproj @@ -76,6 +76,9 @@ DD1E1B4B1DF488D68F6F302D /* ProductInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F4B5781E950F4800D0ED01 /* ProductInformation.swift */; }; DD1E1ED9D4663020C0323F96 /* ProductInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F4B5781E950F4800D0ED01 /* ProductInformation.swift */; }; E9F4B5791E950F4800D0ED01 /* ProductInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F4B5781E950F4800D0ED01 /* ProductInformation.swift */; }; + FAC2688E211C2042005F778B /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC2688D211C2042005F778B /* Theme.swift */; }; + FAC2688F211C2042005F778B /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC2688D211C2042005F778B /* Theme.swift */; }; + FAC26890211C2042005F778B /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC2688D211C2042005F778B /* Theme.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -127,6 +130,7 @@ 903A12B71CE050C70071D8F0 /* LIFXHTTPKit.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = LIFXHTTPKit.h; sourceTree = ""; }; 908631CD1CDC7629006D9E47 /* LIFXHTTPKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LIFXHTTPKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E9F4B5781E950F4800D0ED01 /* ProductInformation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProductInformation.swift; sourceTree = ""; }; + FAC2688D211C2042005F778B /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -205,6 +209,7 @@ 5DA7AB431B377ACA008A8130 /* Color.swift */, 5DB41F4B1B2BD5DD0006F2A5 /* Light.swift */, 5D50E0581BC3A21800AED146 /* Scene.swift */, + FAC2688D211C2042005F778B /* Theme.swift */, 5D50E05A1BC3A26A00AED146 /* State.swift */, E9F4B5781E950F4800D0ED01 /* ProductInformation.swift */, 5D94C6491B5671C300C0BCD2 /* Group.swift */, @@ -494,6 +499,7 @@ 5D94C64C1B56726200C0BCD2 /* Location.swift in Sources */, 5DB41F461B2BD55C0006F2A5 /* LightTargetObserver.swift in Sources */, DD1E1B4B1DF488D68F6F302D /* ProductInformation.swift in Sources */, + FAC2688F211C2042005F778B /* Theme.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -531,6 +537,7 @@ 5D8BF53E1BB5257900A5575C /* Light.swift in Sources */, 5D8BF53D1BB5257600A5575C /* HTTPSession.swift in Sources */, 5D8BF53F1BB5257B00A5575C /* Color.swift in Sources */, + FAC2688E211C2042005F778B /* Theme.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -554,6 +561,7 @@ 903A12CA1CE064070071D8F0 /* Scene.swift in Sources */, 903A12CB1CE064090071D8F0 /* State.swift in Sources */, DD1E132F81EB962FED0C262F /* ProductInformation.swift in Sources */, + FAC26890211C2042005F778B /* Theme.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Source/Client.swift b/Source/Client.swift index 048524d..a8917b1 100644 --- a/Source/Client.swift +++ b/Source/Client.swift @@ -7,15 +7,15 @@ import Foundation public class Client { public let session: HTTPSession - public private(set) var lights: [Light] - public private(set) var scenes: [Scene] + public internal(set) var lights: [Light] + public internal(set) var scenes: [Scene] private var observers: [ClientObserver] - public convenience init(accessToken: String, lights: [Light]? = nil, scenes: [Scene]? = nil) { - self.init(session: HTTPSession(accessToken: accessToken), lights: lights, scenes: scenes) + public convenience init(accessToken: String, lights: [Light]? = nil, scenes: [Scene]? = nil) { + self.init(session: HTTPSession(accessToken: accessToken), lights: lights, scenes: scenes) } - public init(session: HTTPSession, lights: [Light]? = nil, scenes: [Scene]? = nil) { + public init(session: HTTPSession, lights: [Light]? = nil, scenes: [Scene]? = nil) { self.session = session self.lights = lights ?? [] self.scenes = scenes ?? [] @@ -48,27 +48,49 @@ public class Client { } public func fetchLights(completionHandler: ((_ error: Error?) -> Void)? = nil) { + let requestedAt = Date() session.lights("all") { [weak self] (request, response, lights, error) in - if error != nil { + guard let `self` = self, error == nil else { completionHandler?(error) return } - if let strongSelf = self { - let oldLights = strongSelf.lights - let newLights = lights - if oldLights != newLights { - strongSelf.lights = newLights - for observer in strongSelf.observers { - observer.lightsDidUpdateHandler(lights) - } - } - - } - + self.handleUpdated(lights: lights, requestedAt: requestedAt) completionHandler?(nil) } } + + public func fetchLight(_ selector: LightTargetSelector, completionHandler: ((_ error: Error?) -> Void)? = nil) { + guard selector.type != .SceneID else { + completionHandler?(nil) + return + } + let requestedAt = Date() + session.lights(selector.toQueryStringValue()) { [weak self] (request, response, lights, error) in + guard let `self` = self, error == nil else { + completionHandler?(error) + return + } + + self.handleUpdated(lights: lights, requestedAt: requestedAt) + completionHandler?(nil) + } + } + + private func handleUpdated(lights: [Light], requestedAt: Date) { + let oldLights = self.lights + var newLights = lights + if oldLights != newLights { + newLights = newLights.map { newLight in + if let oldLight = oldLights.first(where: { $0.id == newLight.id }), oldLight.isDirty { + return oldLight.light(withUpdatedLight: newLight, requestedAt: requestedAt) + } else { + return newLight + } + } + updateLights(newLights) + } + } public func fetchScenes(completionHandler: ((_ error: Error?) -> Void)? = nil) { session.scenes { [weak self] (request, response, scenes, error) in @@ -87,7 +109,20 @@ public class Client { return lightTargetWithSelector(LightTargetSelector(type: .All)) } + /// Creates a target for API requests with the given selector. If an ID selector is specified and the Light is not already + /// contained in the cache, then a placeholder light will be created so that events can be subscribed to. + /// + /// - Parameter selector: Selector referring to a Scene/Group/Light etc. + /// - Returns: LightTarget which can be used to trigger API requests against the specified Selector public func lightTargetWithSelector(_ selector: LightTargetSelector) -> LightTarget { + switch selector.type { + case .ID: + // Add light to cache if not already present + if !lights.contains(where: { $0.id == selector.value }) { + updateLights([Light(id: selector.value, power: false, brightness: 0, color: Color(hue: 0, saturation: 0, kelvin: 3500), product: nil, label: "", connected: true, inFlightProperties: [], dirtyProperties: [])]) + } + default: break + } return LightTarget(client: self, selector: selector, filter: selectorToFilter(selector)) } @@ -108,26 +143,27 @@ public class Client { func updateLights(_ lights: [Light]) { let oldLights = self.lights - var newLights: [Light] = [] + var newLights: [Light] = lights - for light in lights { - if !newLights.contains(where: { $0.id == light.id }) { - newLights.append(light) - } - } - for light in oldLights { - if !newLights.contains(where: { $0.id == light.id }) { - newLights.append(light) + for oldLight in oldLights { + if !newLights.contains(where: { $0.id == oldLight.id }) { + newLights.append(oldLight) } } + + newLights.sort(by: { $0.id < $1.id }) if oldLights != newLights { + self.lights = newLights for observer in observers { observer.lightsDidUpdateHandler(newLights) } - self.lights = newLights } } + + func updateScenes(_ scenes: [Scene]) { + self.scenes = scenes + } private func selectorToFilter(_ selector: LightTargetSelector) -> LightTargetFilter { switch selector.type { @@ -141,7 +177,7 @@ public class Client { return { (light) in return light.location?.id == selector.value } case .SceneID: return { [weak self] (light) in - if let strongSelf = self, let index = strongSelf.scenes.index(where: { $0.toSelector() == selector }) { + if let strongSelf = self, let index = strongSelf.scenes.firstIndex(where: { $0.toSelector() == selector }) { let scene = strongSelf.scenes[index] return scene.states.contains { (state) in let filter = strongSelf.selectorToFilter(state.selector) diff --git a/Source/Errors.swift b/Source/Errors.swift index 1f21c3e..deda00c 100644 --- a/Source/Errors.swift +++ b/Source/Errors.swift @@ -28,5 +28,25 @@ struct HTTPKitError: Error { self.code = code self.message = message } + + /// Returns an `HTTPKitError` based on the HTTP status code from a response. + init?(statusCode: Int) { + switch statusCode { + case 401: + self.code = .unauthorized + self.message = "Bad access token" + case 403: + self.code = .forbidden + self.message = "Permission denied" + case 429: + self.code = .tooManyRequests + self.message = "Rate limit exceeded" + case 500, 502, 503, 523: + self.code = .unauthorized + self.message = "Server error" + default: + return nil + } + } } diff --git a/Source/HTTPOperation.swift b/Source/HTTPOperation.swift index 04e03d8..4259ab0 100644 --- a/Source/HTTPOperation.swift +++ b/Source/HTTPOperation.swift @@ -5,30 +5,30 @@ import Foundation -class HTTPOperationState { - var cancelled: Bool - var executing: Bool - var finished: Bool +public class HTTPOperationState { + public var cancelled: Bool + public var executing: Bool + public var finished: Bool - init() { + public init() { cancelled = false executing = false finished = false } } -class HTTPOperation: Operation { +public class HTTPOperation: Operation { private let state: HTTPOperationState private let delegateQueue: DispatchQueue private var task: URLSessionDataTask? - init(URLSession: Foundation.URLSession, delegateQueue: DispatchQueue, request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) { + public init(session: URLSession, delegateQueue: DispatchQueue, request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) { state = HTTPOperationState() self.delegateQueue = delegateQueue super.init() - task = URLSession.dataTask(with: request, completionHandler: { [weak self] (data, response, error) in + task = session.dataTask(with: request, completionHandler: { [weak self] (data, response, error) in if let strongSelf = self { strongSelf.isExecuting = false strongSelf.isFinished = true @@ -39,11 +39,11 @@ class HTTPOperation: Operation { }) } - override var isAsynchronous: Bool { + override public var isAsynchronous: Bool { return true } - override private(set) var isCancelled: Bool { + override private(set) public var isCancelled: Bool { get { return state.cancelled } set { willChangeValue(forKey: "isCancelled") @@ -52,7 +52,7 @@ class HTTPOperation: Operation { } } - override private(set) var isExecuting: Bool { + override private(set) public var isExecuting: Bool { get { return state.executing } set { willChangeValue(forKey: "isExecuting") @@ -61,7 +61,7 @@ class HTTPOperation: Operation { } } - override private(set) var isFinished: Bool { + override private(set) public var isFinished: Bool { get { return state.finished } set { willChangeValue(forKey: "isFinished") @@ -70,7 +70,7 @@ class HTTPOperation: Operation { } } - override func start() { + override public func start() { if isCancelled { return } @@ -79,7 +79,7 @@ class HTTPOperation: Operation { isExecuting = true } - override func cancel() { + override public func cancel() { task?.cancel() isCancelled = true } diff --git a/Source/HTTPSession.swift b/Source/HTTPSession.swift index f870b02..85f3727 100644 --- a/Source/HTTPSession.swift +++ b/Source/HTTPSession.swift @@ -6,283 +6,137 @@ import Foundation public class HTTPSession { - public static let defaultBaseURL: URL = URL(string: "https://api.lifx.com/v1/")! - public static let defaultUserAgent: String = "LIFXHTTPKit/\(LIFXHTTPKitVersionNumber)" - public static let defaultTimeout: TimeInterval = 5.0 + + // MARK: - Defaults + + public struct Defaults { + public static let baseURL = URL(string: "https://api.lifx.com/v1/")! + public static let userAgent = "LIFXHTTPKit/\(LIFXHTTPKitVersionNumber)" + public static let timeout: TimeInterval = 5 + } + + // MARK: - Properties public let baseURL: URL public let delegateQueue: DispatchQueue - public let URLSession: Foundation.URLSession + public let session: URLSession private let operationQueue: OperationQueue + private let log: Bool + + // MARK: - Lifecycle - public init(accessToken: String, delegateQueue: DispatchQueue = DispatchQueue(label: "com.tatey.lifx-http-kit.http-session", attributes: []), baseURL: URL = HTTPSession.defaultBaseURL, userAgent: String = HTTPSession.defaultUserAgent, timeout: TimeInterval = HTTPSession.defaultTimeout) { + public init(accessToken: String, delegateQueue: DispatchQueue = DispatchQueue(label: "com.tatey.lifx-http-kit.http-session", attributes: []), baseURL: URL = Defaults.baseURL, userAgent: String = Defaults.userAgent, timeout: TimeInterval = Defaults.timeout, maxRequests: Int = 3, log: Bool = false) { self.baseURL = baseURL self.delegateQueue = delegateQueue + self.log = log - let configuration = URLSessionConfiguration.default + let configuration = URLSessionConfiguration.ephemeral configuration.httpAdditionalHeaders = ["Authorization": "Bearer \(accessToken)", "Accept": "appplication/json", "User-Agent": userAgent] configuration.timeoutIntervalForRequest = timeout - URLSession = Foundation.URLSession(configuration: configuration) + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + session = URLSession(configuration: configuration) operationQueue = OperationQueue() - operationQueue.maxConcurrentOperationCount = 1 + operationQueue.maxConcurrentOperationCount = maxRequests } + /// Lists lights limited by `selector`. + /// GET /lights/{selector} public func lights(_ selector: String = "all", completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ lights: [Light], _ error: Error?) -> Void)) { - var request = URLRequest(url: baseURL.appendingPathComponent("lights/\(selector)")) - request.httpMethod = "GET" - addOperationWithRequest(request as URLRequest) { (data, response, error) in - if let error = error ?? self.validateResponseWithExpectedStatusCodes(response, statusCodes: [200]) { - completionHandler(request as URLRequest, response, [], error) - } else { - let (lights, error) = self.dataToLights(data) - completionHandler(request, response, lights, error) - } - } - } - - public func setLightsPower(_ selector: String, power: Bool, duration: Float, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { - print("`setLightsPower:power:duration:completionHandler:` is deprecated and will be removed in a future version. Use `setLightsState:power:color:brightness:duration:completionHandler:` instead.") - setLightsState(selector, power: power, duration: duration, completionHandler: completionHandler) - } - - public func setLightsColor(_ selector: String, color: String, duration: Float, powerOn: Bool, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { - print("`setLightsColor:color:duration:powerOn:completionHandler:` is deprecated and will be removed in a future version. Use `setLightsState:power:color:brightness:duration:completionHandler:` instead.") - setLightsState(selector, power: powerOn, color: color, duration: duration, completionHandler: completionHandler) - } + let request = HTTPRequest(baseURL: baseURL, path: "lights/\(selector)") + + perform(request: request) { (response: HTTPResponse<[Light]>) in + completionHandler(request.toURLRequest(), response.response, response.body ?? [], response.error) + } + } + /// Sets `power`, `color` or `brightness` (or any combination) over a `duration`, limited by `selector`. + /// PUT /lights/{selector}/state public func setLightsState(_ selector: String, power: Bool? = nil, color: String? = nil, brightness: Double? = nil, duration: Float, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { - var request = URLRequest(url: baseURL.appendingPathComponent("lights/\(selector)/state")) - var parameters: [String : Any] = ["duration": duration as AnyObject] - if let power = power { - parameters["power"] = power ? "on" : "off" as AnyObject? - } - if let color = color { - parameters["color"] = color as AnyObject? - } - if let brightness = brightness { - parameters["brightness"] = brightness as AnyObject? - } - request.httpMethod = "PUT" - request.httpBody = try? JSONSerialization.data(withJSONObject: parameters, options: []) - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - addOperationWithRequest(request as URLRequest) { (data, response, error) in - if let error = error ?? self.validateResponseWithExpectedStatusCodes(response, statusCodes: [200, 207]) { - completionHandler(request as URLRequest, response, [], error) - } else { - let (results, error) = self.dataToResults(data) - completionHandler(request, response, results, error) - } - } + let body = StateRequest(power: power?.asPower, color: color, brightness: brightness, duration: duration, async: true) + let request = HTTPRequest(baseURL: baseURL, path: "lights/\(selector)/state", method: .put, headers: ["Content-Type": "application/json"], body: body, expectedStatusCodes: [200, 207]) + + perform(request: request) { (response: HTTPResponse) in + completionHandler(request.toURLRequest(), response.response, response.body?.results ?? [], response.error) + } } - + + public func togglePower(_ selector: String, duration: Float, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { + let body = TogglePowerRequest(duration: duration) + let request = HTTPRequest(baseURL: baseURL, path: "lights/\(selector)/toggle", method: .post, headers: ["Content-Type": "application/json"], body: body, expectedStatusCodes: [200, 207]) + + perform(request: request) { (response: HTTPResponse) in + completionHandler(request.toURLRequest(), response.response, response.body?.results ?? [], response.error) + } + } + + /// Lists all scenes. + /// GET /scenes public func scenes(_ completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ scenes: [Scene], _ error: Error?) -> Void)) { - var request = URLRequest(url: baseURL.appendingPathComponent("scenes")) - request.httpMethod = "GET" - addOperationWithRequest(request as URLRequest) { (data, response, error) in - if let error = error ?? self.validateResponseWithExpectedStatusCodes(response, statusCodes: [200]) { - completionHandler(request as URLRequest, response, [], error) - } else { - let (scenes, error) = self.dataToScenes(data) - completionHandler(request, response, scenes, error) - } - } - } - - public func setScenesActivate(_ selector: String, duration: Float, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { - var request = URLRequest(url: baseURL.appendingPathComponent("scenes/\(selector)/activate")) - let parameters = ["duration": duration] as [String: Any] - request.httpMethod = "PUT" - request.httpBody = try? JSONSerialization.data(withJSONObject: parameters, options: []) - addOperationWithRequest(request as URLRequest) { (data, response, error) in - if let error = error ?? self.validateResponseWithExpectedStatusCodes(response, statusCodes: [200, 207]) { - completionHandler(request as URLRequest, response, [], error) - } else { - let (results, error) = self.dataToResults(data) - completionHandler(request, response, results, error) - } - } - } - - // MARK: Helpers - - private func addOperationWithRequest(_ request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) { - let operation = HTTPOperation(URLSession: URLSession, delegateQueue: delegateQueue, request: request, completionHandler: completionHandler) - operationQueue.operations.first?.addDependency(operation) - operationQueue.addOperation(operation) - } - - private func validateResponseWithExpectedStatusCodes(_ response: URLResponse?, statusCodes: [Int]) -> Error? { - guard let response = response as? HTTPURLResponse else { - return nil - } - - if statusCodes.contains(response.statusCode) { - return nil - } - - switch (response.statusCode) { - case 401: - return HTTPKitError(code: .unauthorized, message: "Bad access token") - case 403: - return HTTPKitError(code: .forbidden, message: "Permission denied") - case 429: - return HTTPKitError(code: .tooManyRequests, message: "Rate limit exceeded") - case 500, 502, 503, 523: - return HTTPKitError(code: .unauthorized, message: "Server error") - default: - return HTTPKitError(code: .unexpectedHTTPStatusCode, message: "Expecting \(statusCodes), got \(response.statusCode)") - } + let request = HTTPRequest(baseURL: baseURL, path: "scenes") + + perform(request: request) { (response: HTTPResponse<[Scene]>) in + completionHandler(request.toURLRequest(), response.response, response.body ?? [], response.error) + } } - private func dataToLights(_ data: Data?) -> (lights: [Light], error: Error?) { - guard let data = data else { - return ([], HTTPKitError(code: .jsonInvalid, message: "No data")) - } - - let rootJSONObject: Any? - do { - rootJSONObject = try JSONSerialization.jsonObject(with: data, options: []) - } catch let error { - return ([], error) - } - - let lightJSONObjects: [NSDictionary] - if let array = rootJSONObject as? [NSDictionary] { - lightJSONObjects = array - } else { - lightJSONObjects = [] - } - - var lights: [Light] = [] - for lightJSONObject in lightJSONObjects { - if let id = lightJSONObject["id"] as? String, - let power = lightJSONObject["power"] as? String, - let brightness = lightJSONObject["brightness"] as? Double, - let colorJSONObject = lightJSONObject["color"] as? NSDictionary, - let colorHue = colorJSONObject["hue"] as? Double, - let colorSaturation = colorJSONObject["saturation"] as? Double, - let colorKelvin = colorJSONObject["kelvin"] as? Int, - let label = lightJSONObject["label"] as? String, - let connected = lightJSONObject["connected"] as? Bool { - let group: Group? - if let groupJSONObject = lightJSONObject["group"] as? NSDictionary, - let groupId = groupJSONObject["id"] as? String, - let groupName = groupJSONObject["name"] as? String { - group = Group(id: groupId, name: groupName) - } else { - group = nil - } - - let location: Location? - if let locationJSONObject = lightJSONObject["location"] as? NSDictionary, - let locationId = locationJSONObject["id"] as? String, - let locationName = locationJSONObject["name"] as? String { - location = Location(id: locationId, name: locationName) - } else { - location = nil - } - - var productInformation: ProductInformation? - if let productInformationJSONObject = lightJSONObject["product"] as? NSDictionary { - productInformation = ProductInformation(data: productInformationJSONObject) - } - - let color = Color(hue: colorHue, saturation: colorSaturation, kelvin: colorKelvin) - let light = Light(id: id, power: power == "on", brightness: brightness, color: color, productInfo: productInformation, label: label, connected: connected, group: group, location: location, touchedAt: Date()) - lights.append(light) - } else { - return ([], HTTPKitError(code: .jsonInvalid, message: "JSON object is missing required properties")) - } - } - return (lights, nil) - } - - private func dataToScenes(_ data: Data?) -> (scenes: [Scene], error: Error?) { - guard let data = data else { - return ([], HTTPKitError(code: .jsonInvalid, message: "No data")) - } - - let rootJSONObject: Any? - do { - rootJSONObject = try JSONSerialization.jsonObject(with: data, options: []) - } catch let error { - return ([], error) - } - - let sceneJSONObjects: [NSDictionary] - if let array = rootJSONObject as? [NSDictionary] { - sceneJSONObjects = array - } else { - sceneJSONObjects = [] - } - - var scenes: [Scene] = [] - for sceneJSONObject in sceneJSONObjects { - if let uuid = sceneJSONObject["uuid"] as? String, - let name = sceneJSONObject["name"] as? String, - let stateJSONObjects = sceneJSONObject["states"] as? [NSDictionary] { - var states: [State] = [] - for stateJSONObject in stateJSONObjects { - if let rawSelector = stateJSONObject["selector"] as? String, - let selector = LightTargetSelector(stringValue: rawSelector) { - let brightness = stateJSONObject["brightness"] as? Double ?? nil - let color: Color? - if let colorJSONObject = stateJSONObject["color"] as? NSDictionary, - let colorHue = colorJSONObject["hue"] as? Double, - let colorSaturation = colorJSONObject["saturation"] as? Double, - let colorKelvin = colorJSONObject["kelvin"] as? Int { - color = Color(hue: colorHue, saturation: colorSaturation, kelvin: colorKelvin) - } else { - color = nil - } - let power: Bool? - if let powerJSONValue = stateJSONObject["power"] as? String { - power = powerJSONValue == "on" - } else { - power = nil - } - let state = State(selector: selector, brightness: brightness, color: color, power: power) - states.append(state) - } - } - let scene = Scene(uuid: uuid, name: name, states: states) - scenes.append(scene) - } - } - return (scenes, nil) + /// Activates a scene. The `duration` will override the duration stored on each scene device. + /// PUT /scenes/{selector}/activate + public func setScenesActivate(_ selector: String, duration: Float? = nil, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { + let body = SceneRequest(duration: duration, async: true) + let request = HTTPRequest(baseURL: baseURL, path: "scenes/\(selector)/activate", method: .put, headers: ["Content-Type": "application/json"], body: body, expectedStatusCodes: [200, 207]) + + perform(request: request) { (response: HTTPResponse) in + completionHandler(request.toURLRequest(), response.response, response.body?.results ?? [], response.error) + } } + + /// Activates/deactivates clean cycle. The `duration` will only be necessary for active. + /// POST /lights/{selector}/clean + public func setCleanCycle(_ selector: String, active isActive: Bool, duration: Float?, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { + let body = CleanCycleRequest(isActive: isActive, duration: duration) + let request = HTTPRequest(baseURL: baseURL, path: "lights/\(selector)/clean", method: .post, headers: ["Content-Type": "application/json"], body: body, expectedStatusCodes: [200, 207]) + + perform(request: request) { (response: HTTPResponse) in + completionHandler(request.toURLRequest(), response.response, response.body?.results ?? [], response.error) + } + } + + // MARK: - Deprecated + + @available(*, deprecated, message: "Use `setLightsState:power:color:brightness:duration:completionHandler:` instead.") + public func setLightsPower(_ selector: String, power: Bool, duration: Float, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { + setLightsState(selector, power: power, duration: duration, completionHandler: completionHandler) + } + + @available(*, deprecated, message: "Use `setLightsState:power:color:brightness:duration:completionHandler:` instead.") + public func setLightsColor(_ selector: String, color: String, duration: Float, powerOn: Bool, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { + setLightsState(selector, power: powerOn, color: color, duration: duration, completionHandler: completionHandler) + } - private func dataToResults(_ data: Data?) -> (results: [Result], error: Error?) { - guard let data = data else { - return ([], HTTPKitError(code: .jsonInvalid, message: "No data")) - } - - let rootJSONObject: Any - do { - rootJSONObject = try JSONSerialization.jsonObject(with: data, options: []) - } catch let error { - return ([], error) - } - - let resultJSONObjects: [NSDictionary] - if let dictionary = rootJSONObject as? NSDictionary, let array = dictionary["results"] as? [NSDictionary] { - resultJSONObjects = array - } else { - resultJSONObjects = [] - } - - var results: [Result] = [] - for resultJSONObject in resultJSONObjects { - if let id = resultJSONObject["id"] as? String, let status = Result.Status(rawValue: resultJSONObject["status"] as? String ?? "unknown") { - let result = Result(id: id, status: status) - results.append(result) - } else { - return ([], HTTPKitError(code: .jsonInvalid, message: "JSON object is missing required properties")) - } - } - - return (results, nil) - } + // MARK: Private Utils + + /// Performs an `HTTPRequest` with the given parameters and will complete with the an `HTTPResponse`. + func perform(request: HTTPRequest, completion: @escaping (HTTPResponse) -> Void) { + if log { + print(request) + } + let operation = HTTPOperation(session: session, delegateQueue: delegateQueue, request: request.toURLRequest(), completionHandler: { (data, response, error) in + let parsedError = error ?? self.validate(response: response, withExpectedStatusCodes: request.expectedStatusCodes) + let wrapped = HTTPResponse(data: data, response: response, error: parsedError) + completion(wrapped) + if self.log { + print(wrapped) + } + }) + operationQueue.addOperation(operation) + } + + /// Parses the `URLResponse` into any errors based on expected status codes. + private func validate(response: URLResponse?, withExpectedStatusCodes codes: [Int]) -> Error? { + guard let response = response as? HTTPURLResponse, !codes.contains(response.statusCode) else { return nil } + return HTTPKitError(statusCode: response.statusCode) ?? HTTPKitError(code: .unexpectedHTTPStatusCode, message: "Expecting \(codes), got \(response.statusCode)") + } + } diff --git a/Source/Light.swift b/Source/Light.swift deleted file mode 100644 index a82c0ec..0000000 --- a/Source/Light.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Created by Tate Johnson on 13/06/2015. -// Copyright (c) 2015 Tate Johnson. All rights reserved. -// - -import Foundation - -public struct Light: Equatable, CustomStringConvertible { - public let id: String - public let power: Bool - public let brightness: Double - public let color: Color - public let productInfo: ProductInformation? - public let label: String - public let connected: Bool - public let group: Group? - public let location: Location? - public let touchedAt: Date? - - - public func toSelector() -> LightTargetSelector { - return LightTargetSelector(type: .ID, value: id) - } - - func lightWithProperties(_ power: Bool? = nil, brightness: Double? = nil, color: Color? = nil, productInformation: ProductInformation? = nil, connected: Bool? = nil, touchedAt: Date? = nil) -> Light { - return Light(id: id, power: power ?? self.power, brightness: brightness ?? self.brightness, color: color ?? self.color, productInfo: productInformation ?? self.productInfo, label: label, connected: connected ?? self.connected, group: group, location: location, touchedAt: touchedAt ?? Date()) - } - - // MARK: Capabilities - - public var hasColor: Bool { - return self.productInfo?.capabilities?.hasColor ?? false - } - - public var hasIR: Bool { - return self.productInfo?.capabilities?.hasIR ?? false - } - - public var hasMultiZone: Bool { - return self.productInfo?.capabilities?.hasMulitiZone ?? false - } - - // MARK: Printable - - public var description: String { - return "" - } -} - -public func ==(lhs: Light, rhs: Light) -> Bool { - return lhs.id == rhs.id && - lhs.power == rhs.power && - lhs.brightness == rhs.brightness && - lhs.color == rhs.color && - lhs.label == rhs.label && - lhs.connected == rhs.connected && - lhs.group == rhs.group && - lhs.location == rhs.location -} diff --git a/Source/LightTarget.swift b/Source/LightTarget.swift index cc83b0f..93bd281 100644 --- a/Source/LightTarget.swift +++ b/Source/LightTarget.swift @@ -79,48 +79,57 @@ public class LightTarget { } // MARK: Slicing - - public func toLightTargets() -> [LightTarget] { - return lights.map { (light) in return self.client.lightTargetWithSelector(LightTargetSelector(type: .ID, value: light.id)) } - } - - public func toGroupTargets() -> [LightTarget] { - return lights.reduce([]) { (groups, light) -> [Group] in - if let group = light.group, !groups.contains(group) { - return groups + [group] - } else { - return groups - } - }.map { (group) in - return self.client.lightTargetWithSelector(group.toSelector()) - } - } - - public func toLocationTargets() -> [LightTarget] { - return lights.reduce([]) { (locations, light) -> [Location] in - if let location = light.location, !locations.contains(location) { - return locations + [location] - } else { - return locations - } - }.map { (location) in - return self.client.lightTargetWithSelector(location.toSelector()) - } - } - - public func toLights() -> [Light] { - print("`toLights` is deprecated and will be removed in a future version. Use `lights` instead.") - return lights - } + + public func toLightTargets(filter: ((Light) -> Bool) = { _ in true }) -> [LightTarget] { + lights + .filter(filter) + .map { light in + self.client.lightTargetWithSelector(LightTargetSelector(type: .ID, value: light.id)) + } + } + + public func toGroupTargets(filter: ((Light) -> Bool) = { _ in true }) -> [LightTarget] { + lights + .filter(filter) + .reduce([]) { (groups, light) -> [Group] in + if let group = light.group, !groups.contains(group) { + return groups + [group] + } else { + return groups + } + } + .map { (group) in + return self.client.lightTargetWithSelector(group.toSelector()) + } + } + + public func toLocationTargets(filter: ((Light) -> Bool) = { _ in true }) -> [LightTarget] { + lights + .filter(filter) + .reduce([]) { (locations, light) -> [Location] in + if let location = light.location, !locations.contains(location) { + return locations + [location] + } else { + return locations + } + } + .map { (location) in + return self.client.lightTargetWithSelector(location.toSelector()) + } + } // MARK: Lighting Operations + + public func fetch() { + client.fetchLight(selector) + } public func setPower(_ power: Bool, duration: Float = LightTarget.defaultDuration, completionHandler: ((_ results: [Result], _ error: Error?) -> Void)? = nil) { let oldPower = self.power - client.updateLights(lights.map({ $0.lightWithProperties(power) })) + client.updateLights(lights.map({ $0.lightWithProperties(power, inFlightProperties: [.power]) })) client.session.setLightsState(selector.toQueryStringValue(), power: power, duration: duration) { [weak self] (request, response, results, error) in if let strongSelf = self { - var newLights = strongSelf.lightsByDeterminingConnectivityWithResults(strongSelf.lights, results: results) + var newLights = strongSelf.lightsByDeterminingConnectivityWithResults(strongSelf.lights, results: results, removingInFlightProperties: [.power]) if error != nil { newLights = newLights.map({ $0.lightWithProperties(oldPower) }) } @@ -129,13 +138,34 @@ public class LightTarget { completionHandler?(results, error) } } + + public func togglePower(_ duration: Float = LightTarget.defaultDuration, completionHandler: ((_ results: [Result], _ error: Error?) -> Void)? = nil) { + client.updateLights(lights.map({ $0.lightWithProperties(!power, inFlightProperties: [.toggle]) })) + client.session.togglePower(selector.toQueryStringValue(), duration: duration) { [weak self] (request, response, results, error) in + guard let `self` = self else { + completionHandler?(results, error) + return + } + let newLights: [Light] = self + .lightsByDeterminingConnectivityWithResults(self.lights, results: results, removingInFlightProperties: [.toggle]) + .map { light in + guard let result = results.first(where: { $0.id == light.id }), let power = result.power else { + return light + } + return light.lightWithProperties(power == .on) + } + + self.client.updateLights(newLights) + completionHandler?(results, error) + } + } public func setBrightness(_ brightness: Double, duration: Float = LightTarget.defaultDuration, completionHandler: ((_ results: [Result], _ error: Error?) -> Void)? = nil) { let oldBrightness = self.brightness - client.updateLights(lights.map({ $0.lightWithProperties(brightness: brightness) })) + client.updateLights(lights.map({ $0.lightWithProperties(brightness: brightness, inFlightProperties: [.brightness]) })) client.session.setLightsState(selector.toQueryStringValue(), brightness: brightness, duration: duration) { [weak self] (request, response, results, error) in if let strongSelf = self { - var newLights = strongSelf.lightsByDeterminingConnectivityWithResults(strongSelf.lights, results: results) + var newLights = strongSelf.lightsByDeterminingConnectivityWithResults(strongSelf.lights, results: results, removingInFlightProperties: [.brightness]) if error != nil { newLights = newLights.map({ $0.lightWithProperties(brightness: oldBrightness) }) } @@ -147,10 +177,10 @@ public class LightTarget { public func setColor(_ color: Color, duration: Float = LightTarget.defaultDuration, completionHandler: ((_ results: [Result], _ error: Error?) -> Void)? = nil) { let oldColor = self.color - client.updateLights(lights.map({ $0.lightWithProperties(color: color) })) + client.updateLights(lights.map({ $0.lightWithProperties(color: color, inFlightProperties: [.color]) })) client.session.setLightsState(selector.toQueryStringValue(), color: color.toQueryStringValue(), duration: duration) { [weak self] (request, response, results, error) in if let strongSelf = self { - var newLights = strongSelf.lightsByDeterminingConnectivityWithResults(strongSelf.lights, results: results) + var newLights = strongSelf.lightsByDeterminingConnectivityWithResults(strongSelf.lights, results: results, removingInFlightProperties: [.color]) if error != nil { newLights = newLights.map({ $0.lightWithProperties(color: oldColor) }) } @@ -159,6 +189,12 @@ public class LightTarget { completionHandler?(results, error) } } + + public func applyTheme(_ theme: Theme, duration: Float = LightTarget.defaultDuration, completionHandler: ((_ results: [Result], _ error: Error?) -> Void)? = nil) { + client.session.applyTheme(selector.toQueryStringValue(), theme: theme.title, duration: duration) { (request, response, results, error) in + completionHandler?(results, error) + } + } public func setColor(_ color: Color, brightness: Double, power: Bool? = nil, duration: Float = LightTarget.defaultDuration, completionHandler: ((_ results: [Result], _ error: Error?) -> Void)? = nil) { print("`setColor:brightness:power:duration:completionHandler: is deprecated and will be removed in a future version. Use `setState:brightness:power:duration:completionHandler:` instead.") @@ -169,10 +205,10 @@ public class LightTarget { let oldBrightness = self.brightness let oldColor = self.color let oldPower = self.power - client.updateLights(lights.map({ $0.lightWithProperties(power, brightness: brightness, color: color) })) + client.updateLights(lights.map({ $0.lightWithProperties(power, brightness: brightness, color: color, inFlightProperties: [.color, .power, .brightness]) })) client.session.setLightsState(selector.toQueryStringValue(), power: power, color: color?.toQueryStringValue(), brightness: brightness, duration: duration) { [weak self] (request, response, results, error) in if let strongSelf = self { - var newLights = strongSelf.lightsByDeterminingConnectivityWithResults(strongSelf.lights, results: results) + var newLights = strongSelf.lightsByDeterminingConnectivityWithResults(strongSelf.lights, results: results, removingInFlightProperties: [.color, .power, .brightness]) if error != nil { newLights = newLights.map({ $0.lightWithProperties(oldPower, brightness: oldBrightness, color: oldColor) }) } @@ -190,31 +226,31 @@ public class LightTarget { } let states: [State] - if let index = client.scenes.index(where: { $0.toSelector() == selector }) { + if let index = client.scenes.firstIndex(where: { $0.toSelector() == selector }) { states = client.scenes[index].states } else { states = [] } let oldLights = lights let newLights = oldLights.map { (light) -> Light in - if let index = states.index(where: { $0.selector == light.toSelector() }) { + if let index = states.firstIndex(where: { $0.selector == light.toSelector() }) { let state = states[index] let brightness = state.brightness ?? light.brightness let color = state.color ?? light.color let power = state.power ?? light.power - return light.lightWithProperties(power, brightness: brightness, color: color) + return light.lightWithProperties(power, brightness: brightness, color: color, inFlightProperties: [.color, .power, .brightness]) } else { return light } } client.updateLights(newLights) - client.session.setScenesActivate(selector.toQueryStringValue(), duration: duration) { [weak self] (request, response, results, error) in + client.session.setScenesActivate(selector.toQueryStringValue()) { [weak self] (request, response, results, error) in if let strongSelf = self { - var newLights = strongSelf.lightsByDeterminingConnectivityWithResults(strongSelf.lights, results: results) + var newLights = strongSelf.lightsByDeterminingConnectivityWithResults(strongSelf.lights, results: results, removingInFlightProperties: [.color, .power, .brightness]) if error != nil { newLights = newLights.map { (newLight) -> Light in - if let index = oldLights.index(where: { $0.id == newLight.id }) { + if let index = oldLights.firstIndex(where: { $0.id == newLight.id }) { let oldLight = oldLights[index] return oldLight.lightWithProperties(connected: newLight.connected) } else { @@ -235,15 +271,24 @@ public class LightTarget { dirtyCheck() } - private func lightsByDeterminingConnectivityWithResults(_ lights: [Light], results: [Result]) -> [Light] { + private func lightsByDeterminingConnectivityWithResults(_ lights: [Light], results: [Result], removingInFlightProperties toRemove: [Light.MutableProperties]) -> [Light] { + let timestamp = Date() return lights.map { (light) in + let newInFlightProperties = light.inFlightProperties.filter { !toRemove.contains($0) } + var dirtyProperties = light.dirtyProperties + toRemove.forEach { notInFlight in + if !dirtyProperties.contains(where: { $0.property == notInFlight }) { + dirtyProperties.append(Light.DirtyProperty(updatedAt: timestamp, property: notInFlight)) + } + } for result in results { if result.id == light.id { switch result.status { - case .OK: - return light.lightWithProperties(connected: true) + case .OK, .Async: + return light.lightWithProperties(connected: true, inFlightProperties: newInFlightProperties, dirtyProperties: dirtyProperties) case .TimedOut, .Offline: - return light.lightWithProperties(connected: false) + // If failed, use new inFlight which removes the inFlight properties and use old dirtyProperties so that that property is not considered dirty + return light.lightWithProperties(connected: false, inFlightProperties: newInFlightProperties, dirtyProperties: light.dirtyProperties) } } } @@ -315,33 +360,32 @@ public class LightTarget { private func deriveTouchedAt() -> Date { var derivedTouchedAt = self.touchedAt for light in lights { - if let lightTouchedAt = light.touchedAt { - if lightTouchedAt.timeIntervalSince(Date()) < 0 { - derivedTouchedAt = lightTouchedAt as Date - } - } + let lightTouchedAt = light.touchedAt + if lightTouchedAt.timeIntervalSince(Date()) < 0 { + derivedTouchedAt = lightTouchedAt as Date + } } return derivedTouchedAt } private func deriveBrightness() -> Double { - let count = lights.count - if count > 0 { - return lights.filter({ $0.connected }).reduce(0.0, { $1.brightness + $0 }) / Double(count) + let connectedLights = lights.filter { $0.connected } + if connectedLights.count > 0 { + return connectedLights.reduce(0.0, { $1.brightness + $0 }) / Double(connectedLights.count) } else { return 0.0 } } private func deriveColor() -> Color { - let count = lights.count - if count > 1 { + let connectedLights = lights.filter { $0.connected } + if connectedLights.count > 1 { var hueXTotal: Double = 0.0 var hueYTotal: Double = 0.0 var saturationTotal: Double = 0.0 var kelvinTotal: Int = 0 - for light in lights { + for light in connectedLights { let color = light.color hueXTotal += sin(color.hue * 2.0 * .pi / Color.maxHue) hueYTotal += cos(color.hue * 2.0 * .pi / Color.maxHue) @@ -353,10 +397,10 @@ public class LightTarget { hue += 1.0 } hue *= Color.maxHue - let saturation = saturationTotal / Double(count) - let kelvin = kelvinTotal / count + let saturation = saturationTotal / Double(connectedLights.count) + let kelvin = kelvinTotal / connectedLights.count return Color(hue: hue, saturation: saturation, kelvin: kelvin) - } else if let light = lights.first, count == 1 { + } else if let light = connectedLights.first, connectedLights.count == 1 { return light.color } else { return Color(hue: 0, saturation: 0, kelvin: Color.defaultKelvin) @@ -382,7 +426,7 @@ public class LightTarget { return "" } case .SceneID: - if let index = client.scenes.index(where: { $0.toSelector() == selector }) { + if let index = client.scenes.firstIndex(where: { $0.toSelector() == selector }) { return client.scenes[index].name } else { return "" @@ -410,8 +454,16 @@ public class LightTarget { public var supportsIR: Bool { return lights.map { $0.hasIR }.contains(true) } - + + public var supportsHEV: Bool { + return lights.map { $0.hasHEV }.contains(true) + } + public var supportsMultiZone: Bool { return lights.map { $0.hasMultiZone }.contains(true) } + + public var supportsVariableColorTemp: Bool { + return lights.contains(where: { $0.hasVariableColorTemp }) + } } diff --git a/Source/ProductInformation.swift b/Source/ProductInformation.swift deleted file mode 100644 index 2348824..0000000 --- a/Source/ProductInformation.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// ProductInformation.swift -// LIFXHTTPKit -// -// Created by LIFX Laptop on 5/4/17. - -import Foundation - -public struct ProductInformation { - public let productName: String - public let manufacturer: String - public let capabilities: Capabilities? - - public init?(data: NSDictionary) { - guard let name = data["name"] as? String, let company = data["company"] as? String, let productCapabilities = data["capabilities"] as? NSDictionary else { - return nil - } - productName = name - manufacturer = company - capabilities = Capabilities(data: productCapabilities) - } - - var description: String { - return "Name: \(productName) - manufactured by \(manufacturer) Capabilities supported - \(String(describing: capabilities?.description))" - } -} - -public struct Capabilities { - public let hasColor: Bool - public let hasIR: Bool - public let hasMulitiZone: Bool - - public init?(data: NSDictionary) { - guard let hasColor = data["has_color"] as? Bool, - let hasIR = data["has_ir"] as? Bool, let multiZone = data["has_multizone"] as? Bool else { - return nil - } - - self.hasColor = hasColor - self.hasIR = hasIR - self.hasMulitiZone = multiZone - } - - var description: String { - return "Color - \(hasColor), Infra-red \(hasIR), Multiple zones - \(hasMulitiZone)" - } - -} diff --git a/Source/Requests/CleanCycleRequest.swift b/Source/Requests/CleanCycleRequest.swift new file mode 100644 index 0000000..f6a70ed --- /dev/null +++ b/Source/Requests/CleanCycleRequest.swift @@ -0,0 +1,19 @@ +// +// CleanCycleRequest.swift +// Pods +// +// Created by Jason Chan on 4/1/21. +// + +import Foundation + +struct CleanCycleRequest: Encodable { + + private let stop: Bool? + private let duration: Float? + + init(isActive: Bool, duration: Float?) { + self.stop = isActive ? nil : true + self.duration = duration + } +} diff --git a/Source/Requests/HTTPRequest.swift b/Source/Requests/HTTPRequest.swift new file mode 100644 index 0000000..e4b3f1b --- /dev/null +++ b/Source/Requests/HTTPRequest.swift @@ -0,0 +1,76 @@ +// +// HTTPRequest.swift +// LIFXHTTPKit +// +// Created by Megan Efron on 3/10/18. +// + +import Foundation + +private let encoder = JSONEncoder() + +public struct HTTPRequest: CustomStringConvertible { + + public enum Method: String { + case get = "GET" + case put = "PUT" + case post = "POST" + } + + let baseURL: URL + let path: String? + let method: Method + let headers: [String: String]? + let body: T? + let expectedStatusCodes: [Int] + + var url: URL { + var url = baseURL + if let path = path { + url.appendPathComponent(path) + } + return url + } + + init(baseURL: URL, path: String?, method: Method = .get, headers: [String: String]? = nil, body: T? = nil, expectedStatusCodes: [Int] = [200]) { + self.baseURL = baseURL + self.path = path + self.method = method + self.headers = headers + self.body = body + self.expectedStatusCodes = expectedStatusCodes + } + + func toURLRequest() -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + + // Add headers + headers?.forEach({ (key, value) in + request.addValue(value, forHTTPHeaderField: key) + }) + + // Add body if applicable + if let body = body { + request.httpBody = try? encoder.encode(body) + } + + return request + } + + public var description: String { + var description = "REQUEST [\(method.rawValue)] '\(url.path)'" + + if let body = body { + if let data = try? encoder.encode(body), let json = try? JSONSerialization.jsonObject(with: data, options: []) { + description += "\nBody:\n\(json)" + } else { + description += "\nBody:\n\(body)" + } + } + + return description + } +} + +struct EmptyRequest: Encodable { } diff --git a/Source/Requests/SceneRequest.swift b/Source/Requests/SceneRequest.swift new file mode 100644 index 0000000..f8032c0 --- /dev/null +++ b/Source/Requests/SceneRequest.swift @@ -0,0 +1,13 @@ +// +// SceneRequest.swift +// LIFXHTTPKit +// +// Created by Megan Efron on 3/10/18. +// + +import Foundation + +struct SceneRequest: Encodable { + let duration: Float? + let async: Bool? +} diff --git a/Source/Requests/StateRequest.swift b/Source/Requests/StateRequest.swift new file mode 100644 index 0000000..5e8500c --- /dev/null +++ b/Source/Requests/StateRequest.swift @@ -0,0 +1,25 @@ +// +// StateRequest.swift +// LIFXHTTPKit-iOS +// +// Created by Megan Efron on 3/10/18. +// + +import Foundation + +struct StateRequest: Encodable { + enum Power: String, Encodable { + case on, off + } + let power: Power? + let color: String? + let brightness: Double? + let duration: Float + let async: Bool? +} + +extension Bool { + var asPower: StateRequest.Power { + return self ? .on : .off + } +} diff --git a/Source/Requests/ThemeRequest.swift b/Source/Requests/ThemeRequest.swift new file mode 100644 index 0000000..36f8e4b --- /dev/null +++ b/Source/Requests/ThemeRequest.swift @@ -0,0 +1,13 @@ +// +// ThemeRequest.swift +// LIFXHTTPKit-iOS +// +// Created by Megan Efron on 3/10/18. +// + +import UIKit + +struct ThemeRequest: Encodable { + let theme: String + let duration: Float +} diff --git a/Source/Requests/TogglePowerRequest.swift b/Source/Requests/TogglePowerRequest.swift new file mode 100644 index 0000000..e474cec --- /dev/null +++ b/Source/Requests/TogglePowerRequest.swift @@ -0,0 +1,12 @@ +// +// TogglePowerRequest.swift +// LIFXHTTPKit +// +// Created by Alexander Stonehouse on 12/10/18. +// + +import Foundation + +struct TogglePowerRequest: Encodable { + let duration: Float +} diff --git a/Source/Color.swift b/Source/Responses/Color.swift similarity index 51% rename from Source/Color.swift rename to Source/Responses/Color.swift index 556a467..3268b24 100644 --- a/Source/Color.swift +++ b/Source/Responses/Color.swift @@ -5,19 +5,43 @@ import Foundation -public struct Color: Equatable, CustomStringConvertible { +public struct Color: Equatable, Codable, CustomStringConvertible { static let maxHue: Double = 360.0 static let defaultKelvin: Int = 3500 public let hue: Double public let saturation: Double public let kelvin: Int + public let brightness: Double? public init(hue: Double, saturation: Double, kelvin: Int) { self.hue = hue self.saturation = saturation self.kelvin = kelvin + self.brightness = 1 } + + public init?(query: String) { + let components = query.split(separator: ":") + if let first = components.first, first == "kelvin", components.count == 2, let kelvin = Int(components[1]) { + self.hue = 0 + self.saturation = 0 + self.kelvin = kelvin + self.brightness = 1 + } else if components.count == 3, let first = components.first, first == "hue" { + let hueAndSaturation = components[1].split(separator: " ") + if hueAndSaturation.count == 2, let first = hueAndSaturation.first, let hue = Double(first), hueAndSaturation[1] == "saturation", let last = components.last, let saturation = Double(last) { + self.hue = hue + self.saturation = saturation + self.kelvin = Color.defaultKelvin + self.brightness = 1 + } else { + return nil + } + } else { + return nil + } + } public static func color(_ hue: Double, saturation: Double) -> Color { return Color(hue: hue, saturation: saturation, kelvin: Color.defaultKelvin) @@ -35,7 +59,7 @@ public struct Color: Equatable, CustomStringConvertible { return saturation == 0.0 } - func toQueryStringValue() -> String { + public func toQueryStringValue() -> String { if isWhite { return "kelvin:\(kelvin)" } else { diff --git a/Source/Group.swift b/Source/Responses/Group.swift similarity index 88% rename from Source/Group.swift rename to Source/Responses/Group.swift index 169f1af..2eb7501 100644 --- a/Source/Group.swift +++ b/Source/Responses/Group.swift @@ -5,7 +5,7 @@ import Foundation -public struct Group: Equatable, CustomStringConvertible { +public struct Group: Codable, Equatable, CustomStringConvertible { public let id: String public let name: String diff --git a/Source/Responses/HTTPResponse.swift b/Source/Responses/HTTPResponse.swift new file mode 100644 index 0000000..ede26fc --- /dev/null +++ b/Source/Responses/HTTPResponse.swift @@ -0,0 +1,53 @@ +// +// HTTPResponse.swift +// LIFXHTTPKit-iOS +// +// Created by Megan Efron on 3/10/18. +// + +import Foundation + +private let decoder = JSONDecoder() + +struct HTTPResponse: CustomStringConvertible { + + let data: Data? + let body: T? + let response: URLResponse? + let error: Error? + + init(data: Data?, response: URLResponse?, error: Error?) { + var err: Error? = error + self.data = data + if let data = data { + do { + self.body = try decoder.decode(T.self, from: data) + } catch { + self.body = nil + err = error + } + } else { + self.body = nil + } + self.response = response + self.error = err + } + + var description: String { + var description: String = "RESPONSE '\(response?.url?.path ?? "Unknown URL")'" + + if let error = error { + description += "\nError:\n\(error)" + } else if let data = data, let json = try? JSONSerialization.jsonObject(with: data, options: []) { + description += "\nBody:\n\(json)" + } else if let body = body { + description += "\nBody:\n\(body)" + } else { + description += "\nNo message" + } + + return description + } +} + +struct EmptyResponse: Decodable { } diff --git a/Source/Responses/Light.swift b/Source/Responses/Light.swift new file mode 100644 index 0000000..0c99e1c --- /dev/null +++ b/Source/Responses/Light.swift @@ -0,0 +1,186 @@ +// +// Created by Tate Johnson on 13/06/2015. +// Copyright (c) 2015 Tate Johnson. All rights reserved. +// + +import Foundation + +public struct Light: Codable, Equatable, CustomStringConvertible { + /// Toggle differs from a power state change because at the time the change is made the power state is indeterminate + public enum MutableProperties: String, Equatable { + case power, brightness, color, toggle + } + struct DirtyProperty: Equatable { + let updatedAt: Date + let property: MutableProperties + } + + public let id: String + public let power: Bool + public let brightness: Double + public let color: Color + public let product: ProductInformation? + public let label: String + public let connected: Bool + public let group: Group? + public let location: Location? + public let touchedAt = Date() + + /// List of properties which have changes currently 'in-flight'. Any state requests made while the properties are 'in-flight' are inherently out of date. + let inFlightProperties: [MutableProperties] + /// Properties which have been updated, but haven't been reflected in a state request + let dirtyProperties: [DirtyProperty] + + @available(*, deprecated, message: "Use `product` instead.") + public var productInfo: ProductInformation? { + return product + } + + init(id: String, power: Bool, brightness: Double, color: Color, product: ProductInformation?, label: String, connected: Bool, group: Group? = nil, location: Location? = nil, inFlightProperties: [MutableProperties], dirtyProperties: [DirtyProperty]) { + self.id = id + self.power = power + self.brightness = brightness + self.color = color + self.product = product + self.label = label + self.connected = connected + self.group = group + self.location = location + self.inFlightProperties = inFlightProperties + self.dirtyProperties = dirtyProperties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + let on = try container.decode(String.self, forKey: .power) + power = on == "on" + brightness = try container.decode(Double.self, forKey: .brightness) + color = try container.decode(Color.self, forKey: .color) + product = try container.decodeIfPresent(ProductInformation.self, forKey: .product) + label = try container.decode(String.self, forKey: .label) + connected = try container.decode(Bool.self, forKey: .connected) + group = try container.decodeIfPresent(Group.self, forKey: .group) + location = try container.decodeIfPresent(Location.self, forKey: .location) + dirtyProperties = [] + inFlightProperties = [] + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + let powerString = power ? "on" : "off" + try container.encode(powerString, forKey: .power) + try container.encode(brightness, forKey: .brightness) + try container.encode(color, forKey: .color) + try container.encodeIfPresent(product, forKey: .product) + try container.encode(label, forKey: .label) + try container.encode(connected, forKey: .connected) + try container.encode(group, forKey: .group) + try container.encode(location, forKey: .location) + } + + private enum CodingKeys: String, CodingKey { + case id + case power + case brightness + case color + case product + case label + case connected + case group + case location + } + + public var isDirty: Bool { + return dirtyProperties.count > 0 || inFlightProperties.count > 0 + } + + public func toSelector() -> LightTargetSelector { + return LightTargetSelector(type: .ID, value: id) + } + + func lightWithProperties(_ power: Bool? = nil, brightness: Double? = nil, color: Color? = nil, productInformation: ProductInformation? = nil, connected: Bool? = nil, inFlightProperties: [MutableProperties]? = nil, dirtyProperties: [DirtyProperty]? = nil) -> Light { + return Light(id: id, power: power ?? self.power, brightness: brightness ?? self.brightness, color: color ?? self.color, product: productInformation ?? self.product, label: label, connected: connected ?? self.connected, group: group, location: location, inFlightProperties: inFlightProperties ?? self.inFlightProperties, dirtyProperties: dirtyProperties ?? self.dirtyProperties) + } + + /// Creates an updated Light with the given updated state (presumably from the cloud). The requested timestamp is used to determine whether state changes are still + /// dirty. If the request was made before state changes were completed then those properties will remain unchanged (pending a subsequent state update). + /// + /// - Parameters: + /// - updatedLight: Light with updated state from the cloud + /// - requestedAt: Timestamp when the request was started + /// - Returns: Updated Light including new state and any dirty properties + func light(withUpdatedLight updatedLight: Light, requestedAt: Date) -> Light { + var mutLight = updatedLight + let stillDirtyProperties: [DirtyProperty] = dirtyProperties.compactMap { + // Make sure the state info was requested after the dirty property was no longer in-flight + if requestedAt.timeIntervalSince($0.updatedAt) > 0 { + return nil + } + return $0 + } + var dirtyProps: [MutableProperties] = stillDirtyProperties.map { $0.property } + + inFlightProperties.forEach { inFlight in + if !dirtyProps.contains(inFlight) { + dirtyProps.append(inFlight) + } + } + dirtyProps.forEach { dirtyProp in + switch dirtyProp { + case .brightness: + mutLight = mutLight.lightWithProperties(brightness: brightness) + case .color: + mutLight = mutLight.lightWithProperties(color: color) + case .power: + mutLight = mutLight.lightWithProperties(power) + case .toggle: + // Toggle is in flight, so flip whatever the state from the cloud was + mutLight = mutLight.lightWithProperties(!mutLight.power) + } + } + return mutLight.lightWithProperties(inFlightProperties: inFlightProperties, dirtyProperties: stillDirtyProperties) + } + + // MARK: Capabilities + + public var hasColor: Bool { + return self.product?.capabilities?.hasColor ?? false + } + + public var hasIR: Bool { + return self.product?.capabilities?.hasIR ?? false + } + + public var hasHEV: Bool { + return self.product?.capabilities?.hasHEV ?? false + } + + public var hasMultiZone: Bool { + return self.product?.capabilities?.hasMulitiZone ?? false + } + + public var hasVariableColorTemp: Bool { + return self.product?.capabilities?.hasVariableColorTemp ?? false + } + + // MARK: Printable + + public var description: String { + return "" + } +} + +public func == (lhs: Light, rhs: Light) -> Bool { + return lhs.id == rhs.id && + lhs.power == rhs.power && + lhs.brightness == rhs.brightness && + lhs.color == rhs.color && + lhs.label == rhs.label && + lhs.connected == rhs.connected && + lhs.group == rhs.group && + lhs.location == rhs.location && + lhs.inFlightProperties == rhs.inFlightProperties && + lhs.dirtyProperties == rhs.dirtyProperties +} diff --git a/Source/LightTargetSelector.swift b/Source/Responses/LightTargetSelector.swift similarity index 93% rename from Source/LightTargetSelector.swift rename to Source/Responses/LightTargetSelector.swift index 1b8966d..bd1f0ba 100644 --- a/Source/LightTargetSelector.swift +++ b/Source/Responses/LightTargetSelector.swift @@ -33,7 +33,7 @@ public struct LightTargetSelector: Equatable, CustomStringConvertible { if type == .All { self.type = type value = "" - } else if let value = components.last, value.characters.count > 0 { + } else if let value = components.last, value.count > 0 { self.type = type self.value = value } else { @@ -52,7 +52,7 @@ public struct LightTargetSelector: Equatable, CustomStringConvertible { } } - func toQueryStringValue() -> String { + public func toQueryStringValue() -> String { return stringValue } diff --git a/Source/Location.swift b/Source/Responses/Location.swift similarity index 91% rename from Source/Location.swift rename to Source/Responses/Location.swift index 1c18012..2340bdf 100644 --- a/Source/Location.swift +++ b/Source/Responses/Location.swift @@ -5,7 +5,7 @@ import Foundation -public struct Location: Equatable { +public struct Location: Codable, Equatable { public let id: String public let name: String diff --git a/Source/Responses/ProductInformation.swift b/Source/Responses/ProductInformation.swift new file mode 100644 index 0000000..5921b0e --- /dev/null +++ b/Source/Responses/ProductInformation.swift @@ -0,0 +1,76 @@ +// +// ProductInformation.swift +// LIFXHTTPKit +// +// Created by LIFX Laptop on 5/4/17. + +import Foundation + +public struct ProductInformation: Codable { + public let productName: String + public let manufacturer: String + public let capabilities: Capabilities? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + productName = try container.decode(String.self, forKey: .productName) + manufacturer = try container.decode(String.self, forKey: .manufacturer) + capabilities = try container.decodeIfPresent(Capabilities.self, forKey: .capabilities) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(productName, forKey: .productName) + try container.encode(manufacturer, forKey: .manufacturer) + try container.encodeIfPresent(capabilities, forKey: .capabilities) + } + + var description: String { + return "Name: \(productName) - manufactured by \(manufacturer) Capabilities supported - \(String(describing: capabilities?.description))" + } + + private enum CodingKeys: String, CodingKey { + case productName = "name" + case manufacturer = "company" + case capabilities + } +} + +public struct Capabilities: Codable { + public let hasColor: Bool + public let hasIR: Bool + public let hasHEV: Bool + public let hasMulitiZone: Bool + public let hasVariableColorTemp: Bool + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + hasColor = try container.decode(Bool.self, forKey: .hasColor) + hasIR = try container.decode(Bool.self, forKey: .hasIR) + hasHEV = try container.decode(Bool.self, forKey: .hasHEV) + hasMulitiZone = try container.decode(Bool.self, forKey: .hasMultiZone) + hasVariableColorTemp = try container.decode(Bool.self, forKey: .hasVariableColorTemp) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(hasColor, forKey: .hasColor) + try container.encode(hasIR, forKey: .hasIR) + try container.encode(hasHEV, forKey: .hasHEV) + try container.encode(hasMulitiZone, forKey: .hasMultiZone) + try container.encode(hasVariableColorTemp, forKey: .hasVariableColorTemp) + } + + var description: String { + return "Color - \(hasColor), Infra-red \(hasIR), Multiple zones - \(hasMulitiZone)" + } + + private enum CodingKeys: String, CodingKey { + case hasColor = "has_color" + case hasIR = "has_ir" + case hasHEV = "has_hev" + case hasMultiZone = "has_multizone" + case hasVariableColorTemp = "has_variable_color_temp" + } + +} diff --git a/Source/Result.swift b/Source/Responses/Result.swift similarity index 61% rename from Source/Result.swift rename to Source/Responses/Result.swift index 983fe96..429e12c 100644 --- a/Source/Result.swift +++ b/Source/Responses/Result.swift @@ -5,15 +5,25 @@ import Foundation -public struct Result: Equatable, CustomStringConvertible { - public enum Status: String { +public struct Results: Decodable { + public let results: [Result] +} + +public struct Result: Decodable, Equatable, CustomStringConvertible { + public enum Status: String, Codable { case OK = "ok" + case Async = "async" case TimedOut = "timed_out" case Offline = "offline" } - + + public enum Power: String, Decodable { + case on, off + } + public let id: String public let status: Status + public let power: Power? // MARK: Printable diff --git a/Source/Scene.swift b/Source/Responses/Scene.swift similarity index 91% rename from Source/Scene.swift rename to Source/Responses/Scene.swift index 29b6ec7..d9d340a 100644 --- a/Source/Scene.swift +++ b/Source/Responses/Scene.swift @@ -5,7 +5,7 @@ import Foundation -public struct Scene: Equatable { +public struct Scene: Codable, Equatable { public let uuid: String public let name: String public let states: [State] diff --git a/Source/Responses/State.swift b/Source/Responses/State.swift new file mode 100644 index 0000000..10088fe --- /dev/null +++ b/Source/Responses/State.swift @@ -0,0 +1,54 @@ +// +// Created by Tate Johnson on 6/10/2015. +// Copyright © 2015 Tate Johnson. All rights reserved. +// + +import Foundation + +public struct State: Codable, Equatable { + public let selector: LightTargetSelector + public let brightness: Double? + public let color: Color? + public let power: Bool? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let selectorValue = try container.decode(String.self, forKey: .selector) + guard let selector = LightTargetSelector(stringValue: selectorValue) else { + throw Errors.invalidSelector + } + self.selector = selector + + let on = try container.decodeIfPresent(String.self, forKey: .power) + power = on == "on" + brightness = try container.decodeIfPresent(Double.self, forKey: .brightness) + color = try container.decodeIfPresent(Color.self, forKey: .color) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(selector.toQueryStringValue(), forKey: .selector) + let powerString = (power ?? false) ? "on" : "off" + try container.encode(powerString, forKey: .power) + try container.encode(brightness, forKey: .brightness) + try container.encode(color, forKey: .color) + } + + private enum CodingKeys: String, CodingKey { + case selector + case brightness + case color + case power + } + + enum Errors: Error { + case invalidSelector + } +} + +public func ==(lhs: State, rhs: State) -> Bool { + return lhs.brightness == rhs.brightness && + lhs.color == rhs.color && + lhs.selector == rhs.selector +} diff --git a/Source/State.swift b/Source/State.swift deleted file mode 100644 index 598c550..0000000 --- a/Source/State.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Created by Tate Johnson on 6/10/2015. -// Copyright © 2015 Tate Johnson. All rights reserved. -// - -import Foundation - -public struct State: Equatable { - public let selector: LightTargetSelector - public let brightness: Double? - public let color: Color? - public let power: Bool? -} - -public func ==(lhs: State, rhs: State) -> Bool { - return lhs.brightness == rhs.brightness && - lhs.color == rhs.color && - lhs.selector == rhs.selector -}