From a0e3b4b8d2994985ee8a7e3465f4138672dbf324 Mon Sep 17 00:00:00 2001 From: Alex Stonehouse Date: Wed, 29 Aug 2018 19:16:25 +1000 Subject: [PATCH 01/21] Added CocoaPods Support (#5) * Added podspec * Fixed warning --- LIFXHTTPKit.podspec | 29 +++++++++++++++++++++++++++++ Source/LightTargetSelector.swift | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 LIFXHTTPKit.podspec 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/Source/LightTargetSelector.swift b/Source/LightTargetSelector.swift index 1b8966d..c48e7f6 100644 --- a/Source/LightTargetSelector.swift +++ b/Source/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 { From 6681102f8e5fe3c57296b477dd1886afc7ce182f Mon Sep 17 00:00:00 2001 From: Alex Stonehouse Date: Thu, 9 Aug 2018 19:52:18 +1000 Subject: [PATCH 02/21] Added basic theme support --- LIFXHTTPKit.xcodeproj/project.pbxproj | 8 +++ Source/Client.swift | 21 ++++++-- Source/HTTPSession.swift | 76 +++++++++++++++++++++++++++ Source/LightTarget.swift | 6 +++ Source/Theme.swift | 16 ++++++ 5 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 Source/Theme.swift 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..c5aa2fb 100644 --- a/Source/Client.swift +++ b/Source/Client.swift @@ -9,16 +9,18 @@ public class Client { public let session: HTTPSession public private(set) var lights: [Light] public private(set) var scenes: [Scene] + public private(set) var themes: [Theme] 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, themes: [Theme]? = nil) { + self.init(session: HTTPSession(accessToken: accessToken), lights: lights, scenes: scenes, themes: themes) } - public init(session: HTTPSession, lights: [Light]? = nil, scenes: [Scene]? = nil) { + public init(session: HTTPSession, lights: [Light]? = nil, scenes: [Scene]? = nil, themes: [Theme]? = nil) { self.session = session self.lights = lights ?? [] self.scenes = scenes ?? [] + self.themes = themes ?? [] observers = [] } @@ -82,6 +84,19 @@ public class Client { completionHandler?(nil) } } + + public func fetchThemes(completionHandler: ((_ error: Error?) -> Void)? = nil) { + session.curatedThemes { [weak self] (request, response, themes, error) in + if error != nil { + completionHandler?(error) + return + } + + self?.themes = themes + + completionHandler?(nil) + } + } public func allLightTarget() -> LightTarget { return lightTargetWithSelector(LightTargetSelector(type: .All)) diff --git a/Source/HTTPSession.swift b/Source/HTTPSession.swift index f870b02..10a2a12 100644 --- a/Source/HTTPSession.swift +++ b/Source/HTTPSession.swift @@ -104,6 +104,40 @@ public class HTTPSession { } } } + + public func curatedThemes(_ completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ themes: [Theme], _ error: Error?) -> Void)) { + guard let curatedThemesURL = URL(string: "https://cloud.lifx.com/themes/v1/curated") else { + return + } + var request = URLRequest(url: curatedThemesURL) + 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 (themes, error) = self.dataToThemes(data) + completionHandler(request, response, themes, error) + } + } + } + + public func applyTheme(_ selector: String, theme: String, duration: Float, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { + var request = URLRequest(url: baseURL.appendingPathComponent("themes/\(selector)")) + let parameters = [ + "theme": theme, + "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]) { + completionHandler(request as URLRequest, response, [], error) + } else { + let (results, error) = self.dataToResults(data) + completionHandler(request, response, results, error) + } + } + } // MARK: Helpers @@ -253,6 +287,48 @@ public class HTTPSession { } return (scenes, nil) } + + private func dataToThemes(_ data: Data?) -> (themes: [Theme], 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 themeJSONObjects: [NSDictionary] + if let array = rootJSONObject as? [NSDictionary] { + themeJSONObjects = array + } else { + themeJSONObjects = [] + } + + var themes: [Theme] = [] + for themeJSONObject in themeJSONObjects { + if let uuid = themeJSONObject["uuid"] as? String, + let title = themeJSONObject["title"] as? String, + let imageUrl = themeJSONObject["image_url"] as? String, + let colorsRaw = themeJSONObject["colors"] as? [[String: Any]] { + + let colors = colorsRaw.compactMap { raw -> Color? in + if let hue = raw["hue"] as? Double, + let saturation = raw["saturation"] as? Double, + let kelvin = raw["kelvin"] as? Int { + return Color(hue: hue, saturation: saturation, kelvin: kelvin) + } + return nil + } + themes.append(Theme(uuid: uuid, title: title, imageUrl: imageUrl, colors: colors)) + } else { + return ([], HTTPKitError(code: .jsonInvalid, message: "JSON object is missing required properties")) + } + } + return (themes, nil) + } private func dataToResults(_ data: Data?) -> (results: [Result], error: Error?) { guard let data = data else { diff --git a/Source/LightTarget.swift b/Source/LightTarget.swift index cc83b0f..a518264 100644 --- a/Source/LightTarget.swift +++ b/Source/LightTarget.swift @@ -159,6 +159,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.") diff --git a/Source/Theme.swift b/Source/Theme.swift new file mode 100644 index 0000000..ebc909c --- /dev/null +++ b/Source/Theme.swift @@ -0,0 +1,16 @@ +// +// Theme.swift +// LIFXHTTPKit +// +// Created by Alexander Stonehouse on 9/8/18. +// Copyright © 2018 Tate Johnson. All rights reserved. +// + +import Foundation + +public struct Theme: Equatable { + public let uuid: String + public let title: String + public let imageUrl: String + public let colors: [Color] +} From 2d50d3891b4de2d3a134a64fb04f981f8a2b2815 Mon Sep 17 00:00:00 2001 From: Alex Stonehouse Date: Thu, 9 Aug 2018 22:35:32 +1000 Subject: [PATCH 03/21] Updated supported status code for apply theme --- Source/HTTPSession.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/HTTPSession.swift b/Source/HTTPSession.swift index 10a2a12..00cd86c 100644 --- a/Source/HTTPSession.swift +++ b/Source/HTTPSession.swift @@ -130,7 +130,7 @@ public class HTTPSession { 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]) { + if let error = error ?? self.validateResponseWithExpectedStatusCodes(response, statusCodes: [200, 207]) { completionHandler(request as URLRequest, response, [], error) } else { let (results, error) = self.dataToResults(data) From 2a4130dd1be71baf0bd0361c46b7a42d7f77e416 Mon Sep 17 00:00:00 2001 From: Alex Stonehouse Date: Thu, 6 Sep 2018 16:32:36 +1000 Subject: [PATCH 04/21] Added query string init for Color --- Source/Color.swift | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Source/Color.swift b/Source/Color.swift index 556a467..6c80e47 100644 --- a/Source/Color.swift +++ b/Source/Color.swift @@ -18,6 +18,26 @@ public struct Color: Equatable, CustomStringConvertible { self.saturation = saturation self.kelvin = kelvin } + + 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 + } else if components.count == 3, let first = components.first, first == "hue" { + let hueAndSaturation = components[1].split(separator: "0") + 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 + } 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 +55,7 @@ public struct Color: Equatable, CustomStringConvertible { return saturation == 0.0 } - func toQueryStringValue() -> String { + public func toQueryStringValue() -> String { if isWhite { return "kelvin:\(kelvin)" } else { From ca124d887f7e25431ac34a68be9ff0b2abcb00c1 Mon Sep 17 00:00:00 2001 From: Alex Stonehouse Date: Fri, 7 Sep 2018 14:00:12 +1000 Subject: [PATCH 05/21] Fix to parsing of color query string --- Source/Color.swift | 2 +- Source/LightTargetSelector.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/Color.swift b/Source/Color.swift index 6c80e47..84ba398 100644 --- a/Source/Color.swift +++ b/Source/Color.swift @@ -26,7 +26,7 @@ public struct Color: Equatable, CustomStringConvertible { self.saturation = 0 self.kelvin = kelvin } else if components.count == 3, let first = components.first, first == "hue" { - let hueAndSaturation = components[1].split(separator: "0") + 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 diff --git a/Source/LightTargetSelector.swift b/Source/LightTargetSelector.swift index c48e7f6..bd1f0ba 100644 --- a/Source/LightTargetSelector.swift +++ b/Source/LightTargetSelector.swift @@ -52,7 +52,7 @@ public struct LightTargetSelector: Equatable, CustomStringConvertible { } } - func toQueryStringValue() -> String { + public func toQueryStringValue() -> String { return stringValue } From a50e92923492d47aa215cd22419ee420c7732cec Mon Sep 17 00:00:00 2001 From: Alex Stonehouse Date: Sat, 8 Sep 2018 22:51:51 +1000 Subject: [PATCH 06/21] Simplified Theme JSON parsing --- Source/Color.swift | 2 +- Source/HTTPSession.swift | 36 ++++-------------------------------- Source/Theme.swift | 10 ++++++++-- 3 files changed, 13 insertions(+), 35 deletions(-) diff --git a/Source/Color.swift b/Source/Color.swift index 84ba398..78c33c2 100644 --- a/Source/Color.swift +++ b/Source/Color.swift @@ -5,7 +5,7 @@ import Foundation -public struct Color: Equatable, CustomStringConvertible { +public struct Color: Equatable, Codable, CustomStringConvertible { static let maxHue: Double = 360.0 static let defaultKelvin: Int = 3500 diff --git a/Source/HTTPSession.swift b/Source/HTTPSession.swift index 00cd86c..00a8e92 100644 --- a/Source/HTTPSession.swift +++ b/Source/HTTPSession.swift @@ -293,41 +293,13 @@ public class HTTPSession { return ([], HTTPKitError(code: .jsonInvalid, message: "No data")) } - let rootJSONObject: Any? + let decoder = JSONDecoder() do { - rootJSONObject = try JSONSerialization.jsonObject(with: data, options: []) + let themes = try decoder.decode([Theme].self, from: data) + return (themes, nil) } catch let error { - return ([], error) + return ([], HTTPKitError(code: .jsonInvalid, message: "JSON object is missing required properties")) } - - let themeJSONObjects: [NSDictionary] - if let array = rootJSONObject as? [NSDictionary] { - themeJSONObjects = array - } else { - themeJSONObjects = [] - } - - var themes: [Theme] = [] - for themeJSONObject in themeJSONObjects { - if let uuid = themeJSONObject["uuid"] as? String, - let title = themeJSONObject["title"] as? String, - let imageUrl = themeJSONObject["image_url"] as? String, - let colorsRaw = themeJSONObject["colors"] as? [[String: Any]] { - - let colors = colorsRaw.compactMap { raw -> Color? in - if let hue = raw["hue"] as? Double, - let saturation = raw["saturation"] as? Double, - let kelvin = raw["kelvin"] as? Int { - return Color(hue: hue, saturation: saturation, kelvin: kelvin) - } - return nil - } - themes.append(Theme(uuid: uuid, title: title, imageUrl: imageUrl, colors: colors)) - } else { - return ([], HTTPKitError(code: .jsonInvalid, message: "JSON object is missing required properties")) - } - } - return (themes, nil) } private func dataToResults(_ data: Data?) -> (results: [Result], error: Error?) { diff --git a/Source/Theme.swift b/Source/Theme.swift index ebc909c..af0631e 100644 --- a/Source/Theme.swift +++ b/Source/Theme.swift @@ -8,9 +8,15 @@ import Foundation -public struct Theme: Equatable { +public struct Theme: Equatable, Codable { public let uuid: String public let title: String - public let imageUrl: String + public let analytics: String + public let image_url: String + public let order: Int public let colors: [Color] } + +public func == (lhs: Theme, rhs: Theme) -> Bool { + return lhs.uuid == rhs.uuid +} From c2c70f8a1312f50841c51793dc5c608bc66a783c Mon Sep 17 00:00:00 2001 From: Alex Stonehouse Date: Sat, 8 Sep 2018 23:40:24 +1000 Subject: [PATCH 07/21] Added invocation phrase to theme model --- Source/Theme.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Source/Theme.swift b/Source/Theme.swift index af0631e..a3b083d 100644 --- a/Source/Theme.swift +++ b/Source/Theme.swift @@ -11,6 +11,7 @@ import Foundation public struct Theme: Equatable, Codable { public let uuid: String public let title: String + public let invocation: String? public let analytics: String public let image_url: String public let order: Int From 1318a6ca9d8859a8be4634cf5b4f04a7b8459270 Mon Sep 17 00:00:00 2001 From: Alex Stonehouse Date: Sun, 9 Sep 2018 14:08:28 +1000 Subject: [PATCH 08/21] Made HTTPOperation public --- Source/HTTPOperation.swift | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Source/HTTPOperation.swift b/Source/HTTPOperation.swift index 04e03d8..caedb88 100644 --- a/Source/HTTPOperation.swift +++ b/Source/HTTPOperation.swift @@ -5,24 +5,24 @@ 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(URLSession: Foundation.URLSession, delegateQueue: DispatchQueue, request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) { state = HTTPOperationState() self.delegateQueue = delegateQueue @@ -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 } From 54625e13aa42be58c8ba9cfd9b5bd79ddceb38ad Mon Sep 17 00:00:00 2001 From: Megan Date: Fri, 5 Oct 2018 11:43:24 +1000 Subject: [PATCH 09/21] Codable models + HTTPSession updates --- Source/Errors.swift | 20 + Source/HTTPOperation.swift | 4 +- Source/HTTPSession.swift | 361 ++++-------------- Source/Light.swift | 59 --- Source/LightTarget.swift | 9 +- Source/ProductInformation.swift | 48 --- Source/Requests/HTTPRequest.swift | 58 +++ Source/Requests/SceneRequest.swift | 12 + Source/Requests/StateRequest.swift | 15 + Source/Requests/ThemeRequest.swift | 13 + Source/{ => Responses}/Color.swift | 0 Source/{ => Responses}/Group.swift | 2 +- Source/Responses/HTTPResponse.swift | 29 ++ Source/Responses/Light.swift | 101 +++++ .../{ => Responses}/LightTargetSelector.swift | 0 Source/{ => Responses}/Location.swift | 2 +- Source/Responses/ProductInformation.swift | 54 +++ Source/{ => Responses}/Result.swift | 8 +- Source/{ => Responses}/Scene.swift | 2 +- Source/Responses/State.swift | 45 +++ Source/{ => Responses}/Theme.swift | 0 Source/State.swift | 19 - 22 files changed, 434 insertions(+), 427 deletions(-) delete mode 100644 Source/Light.swift delete mode 100644 Source/ProductInformation.swift create mode 100644 Source/Requests/HTTPRequest.swift create mode 100644 Source/Requests/SceneRequest.swift create mode 100644 Source/Requests/StateRequest.swift create mode 100644 Source/Requests/ThemeRequest.swift rename Source/{ => Responses}/Color.swift (100%) rename Source/{ => Responses}/Group.swift (88%) create mode 100644 Source/Responses/HTTPResponse.swift create mode 100644 Source/Responses/Light.swift rename Source/{ => Responses}/LightTargetSelector.swift (100%) rename Source/{ => Responses}/Location.swift (91%) create mode 100644 Source/Responses/ProductInformation.swift rename Source/{ => Responses}/Result.swift (73%) rename Source/{ => Responses}/Scene.swift (91%) create mode 100644 Source/Responses/State.swift rename Source/{ => Responses}/Theme.swift (100%) delete mode 100644 Source/State.swift 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 caedb88..4259ab0 100644 --- a/Source/HTTPOperation.swift +++ b/Source/HTTPOperation.swift @@ -22,13 +22,13 @@ public class HTTPOperation: Operation { private let delegateQueue: DispatchQueue private var task: URLSessionDataTask? - public 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 diff --git a/Source/HTTPSession.swift b/Source/HTTPSession.swift index 00a8e92..2babd35 100644 --- a/Source/HTTPSession.swift +++ b/Source/HTTPSession.swift @@ -6,331 +6,114 @@ 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 + + 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 + + // 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) { self.baseURL = baseURL self.delegateQueue = delegateQueue let configuration = URLSessionConfiguration.default configuration.httpAdditionalHeaders = ["Authorization": "Bearer \(accessToken)", "Accept": "appplication/json", "User-Agent": userAgent] configuration.timeoutIntervalForRequest = timeout - URLSession = Foundation.URLSession(configuration: configuration) + session = URLSession(configuration: configuration) operationQueue = OperationQueue() operationQueue.maxConcurrentOperationCount = 1 } 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) + } + } 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, color: color, brightness: brightness, duration: duration) + 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 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) - } - } + let request = HTTPRequest(baseURL: baseURL, path: "scenes") + + perform(request: request) { (response: HTTPResponse<[Scene]>) in + completionHandler(request.toURLRequest(), response.response, response.body ?? [], response.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) - } - } + let body = SceneRequest(duration: duration) + 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) + } } public func curatedThemes(_ completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ themes: [Theme], _ error: Error?) -> Void)) { - guard let curatedThemesURL = URL(string: "https://cloud.lifx.com/themes/v1/curated") else { - return - } - var request = URLRequest(url: curatedThemesURL) - 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 (themes, error) = self.dataToThemes(data) - completionHandler(request, response, themes, error) - } + guard let curatedThemesURL = URL(string: "https://cloud.lifx.com/themes/v1/curated") else { return } + let request = HTTPRequest(baseURL: curatedThemesURL, path: nil) + + perform(request: request) { (response: HTTPResponse<[Theme]>) in + completionHandler(request.toURLRequest(), response.response, response.body ?? [], response.error) } } public func applyTheme(_ selector: String, theme: String, duration: Float, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { - var request = URLRequest(url: baseURL.appendingPathComponent("themes/\(selector)")) - let parameters = [ - "theme": theme, - "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) - } + let body = ThemeRequest(theme: theme, duration: duration) + let request = HTTPRequest(baseURL: baseURL, path: "themes/\(selector)", 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) } } - - // 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)") - } - } - - 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) - } - private func dataToThemes(_ data: Data?) -> (themes: [Theme], error: Error?) { - guard let data = data else { - return ([], HTTPKitError(code: .jsonInvalid, message: "No data")) - } - - let decoder = JSONDecoder() - do { - let themes = try decoder.decode([Theme].self, from: data) - return (themes, nil) - } catch let error { - return ([], HTTPKitError(code: .jsonInvalid, message: "JSON object is missing required properties")) - } + // 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: Internal Utilities + + private func perform(request: HTTPRequest, completion: @escaping (HTTPResponse) -> Void) { + 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) + completion(HTTPResponse(data: data, response: response, error: parsedError)) + }) + operationQueue.operations.first?.addDependency(operation) + operationQueue.addOperation(operation) + } + + 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 a518264..4b424c4 100644 --- a/Source/LightTarget.swift +++ b/Source/LightTarget.swift @@ -321,11 +321,10 @@ 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 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/HTTPRequest.swift b/Source/Requests/HTTPRequest.swift new file mode 100644 index 0000000..284b0dc --- /dev/null +++ b/Source/Requests/HTTPRequest.swift @@ -0,0 +1,58 @@ +// +// HTTPRequest.swift +// LIFXHTTPKit +// +// Created by Megan Efron on 3/10/18. +// + +import Foundation + +private let encoder = JSONEncoder() + +public struct HTTPRequest { + + public enum Method: String { + case get = "GET" + case put = "PUT" + } + + let baseURL: URL + let path: String? + let method: Method + let headers: [String: String]? + let body: T? + let expectedStatusCodes: [Int] + + 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 url = baseURL + if let path = path { + url.appendPathComponent(path) + } + + 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 + } +} + +struct EmptyRequest: Encodable { } diff --git a/Source/Requests/SceneRequest.swift b/Source/Requests/SceneRequest.swift new file mode 100644 index 0000000..1ff7088 --- /dev/null +++ b/Source/Requests/SceneRequest.swift @@ -0,0 +1,12 @@ +// +// SceneRequest.swift +// LIFXHTTPKit +// +// Created by Megan Efron on 3/10/18. +// + +import Foundation + +struct SceneRequest: Encodable { + let duration: Float +} diff --git a/Source/Requests/StateRequest.swift b/Source/Requests/StateRequest.swift new file mode 100644 index 0000000..e1db0da --- /dev/null +++ b/Source/Requests/StateRequest.swift @@ -0,0 +1,15 @@ +// +// StateRequest.swift +// LIFXHTTPKit-iOS +// +// Created by Megan Efron on 3/10/18. +// + +import Foundation + +struct StateRequest: Encodable { + let power: Bool? + let color: String? + let brightness: Double? + let duration: Float +} 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/Color.swift b/Source/Responses/Color.swift similarity index 100% rename from Source/Color.swift rename to Source/Responses/Color.swift 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..96c3b89 --- /dev/null +++ b/Source/Responses/HTTPResponse.swift @@ -0,0 +1,29 @@ +// +// HTTPResponse.swift +// LIFXHTTPKit-iOS +// +// Created by Megan Efron on 3/10/18. +// + +import Foundation + +private let decoder = JSONDecoder() + +struct HTTPResponse { + + let body: T? + let response: URLResponse? + let error: Error? + + init(data: Data?, response: URLResponse?, error: Error?) { + if let data = data { + self.body = try? decoder.decode(T.self, from: data) + } else { + self.body = nil + } + self.response = response + self.error = error + } +} + +struct EmptyResponse: Decodable { } diff --git a/Source/Responses/Light.swift b/Source/Responses/Light.swift new file mode 100644 index 0000000..4903ccf --- /dev/null +++ b/Source/Responses/Light.swift @@ -0,0 +1,101 @@ +// +// Created by Tate Johnson on 13/06/2015. +// Copyright (c) 2015 Tate Johnson. All rights reserved. +// + +import Foundation + +public struct Light: Decodable, Equatable, CustomStringConvertible { + 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() + + @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) { + 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 + } + + 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) + } + + private enum CodingKeys: String, CodingKey { + case id + case power + case brightness + case color + case product + case label + case connected + case group + case location + } + + 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) -> 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) + } + + // 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/LightTargetSelector.swift b/Source/Responses/LightTargetSelector.swift similarity index 100% rename from Source/LightTargetSelector.swift rename to Source/Responses/LightTargetSelector.swift 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..1e45ca4 --- /dev/null +++ b/Source/Responses/ProductInformation.swift @@ -0,0 +1,54 @@ +// +// ProductInformation.swift +// LIFXHTTPKit +// +// Created by LIFX Laptop on 5/4/17. + +import Foundation + +public struct ProductInformation: Decodable { + 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) + } + + 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: Decodable { + public let hasColor: Bool + public let hasIR: Bool + public let hasMulitiZone: 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) + hasMulitiZone = try container.decode(Bool.self, forKey: .hasMultiZone) + } + + 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 hasMultiZone = "has_multizone" + } + +} diff --git a/Source/Result.swift b/Source/Responses/Result.swift similarity index 73% rename from Source/Result.swift rename to Source/Responses/Result.swift index 983fe96..42c80b8 100644 --- a/Source/Result.swift +++ b/Source/Responses/Result.swift @@ -5,8 +5,12 @@ 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 TimedOut = "timed_out" case Offline = "offline" 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..77b5f86 100644 --- a/Source/Scene.swift +++ b/Source/Responses/Scene.swift @@ -5,7 +5,7 @@ import Foundation -public struct Scene: Equatable { +public struct Scene: Decodable, 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..55fbada --- /dev/null +++ b/Source/Responses/State.swift @@ -0,0 +1,45 @@ +// +// Created by Tate Johnson on 6/10/2015. +// Copyright © 2015 Tate Johnson. All rights reserved. +// + +import Foundation + +public struct State: Decodable, 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.decode(String.self, forKey: .power) + power = on == "on" + brightness = try container.decode(Double.self, forKey: .brightness) + color = try container.decode(Color.self, 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/Theme.swift b/Source/Responses/Theme.swift similarity index 100% rename from Source/Theme.swift rename to Source/Responses/Theme.swift 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 -} From 29c097a6dcca15a5885253a0556fb68ffca9b87f Mon Sep 17 00:00:00 2001 From: Megan Date: Sun, 7 Oct 2018 11:21:45 +1100 Subject: [PATCH 10/21] Adding comments and logs --- Source/HTTPSession.swift | 34 ++++++++++++++++++++++++----- Source/Requests/HTTPRequest.swift | 29 +++++++++++++++++++----- Source/Requests/StateRequest.swift | 11 +++++++++- Source/Responses/HTTPResponse.swift | 20 ++++++++++++++++- 4 files changed, 81 insertions(+), 13 deletions(-) diff --git a/Source/HTTPSession.swift b/Source/HTTPSession.swift index 2babd35..45239ac 100644 --- a/Source/HTTPSession.swift +++ b/Source/HTTPSession.swift @@ -7,6 +7,8 @@ import Foundation public class HTTPSession { + // MARK: - Defaults + public struct Defaults { public static let baseURL = URL(string: "https://api.lifx.com/v1/")! public static let userAgent = "LIFXHTTPKit/\(LIFXHTTPKitVersionNumber)" @@ -20,12 +22,14 @@ public class HTTPSession { 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 = Defaults.baseURL, userAgent: String = Defaults.userAgent, timeout: TimeInterval = Defaults.timeout) { + 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, log: Bool = false) { self.baseURL = baseURL self.delegateQueue = delegateQueue + self.log = log let configuration = URLSessionConfiguration.default configuration.httpAdditionalHeaders = ["Authorization": "Bearer \(accessToken)", "Accept": "appplication/json", "User-Agent": userAgent] @@ -36,6 +40,8 @@ public class HTTPSession { operationQueue.maxConcurrentOperationCount = 1 } + /// 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)) { let request = HTTPRequest(baseURL: baseURL, path: "lights/\(selector)") @@ -44,15 +50,18 @@ public class HTTPSession { } } + /// 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)) { - let body = StateRequest(power: power, color: color, brightness: brightness, duration: duration) + let body = StateRequest(power: power?.asPower, color: color, brightness: brightness, duration: duration) 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) } } - + /// Lists all scenes. + /// GET /scenes public func scenes(_ completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ scenes: [Scene], _ error: Error?) -> Void)) { let request = HTTPRequest(baseURL: baseURL, path: "scenes") @@ -61,6 +70,8 @@ public class HTTPSession { } } + /// Activates a scene over a `duration`. + /// PUT /scenes/{selector}/activate public func setScenesActivate(_ selector: String, duration: Float, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { let body = SceneRequest(duration: duration) let request = HTTPRequest(baseURL: baseURL, path: "scenes/\(selector)/activate", method: .put, headers: ["Content-Type": "application/json"], body: body, expectedStatusCodes: [200, 207]) @@ -70,6 +81,8 @@ public class HTTPSession { } } + /// List curated themes. + /// GET https://cloud.lifx.com/themes/v1/curated public func curatedThemes(_ completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ themes: [Theme], _ error: Error?) -> Void)) { guard let curatedThemesURL = URL(string: "https://cloud.lifx.com/themes/v1/curated") else { return } let request = HTTPRequest(baseURL: curatedThemesURL, path: nil) @@ -79,6 +92,8 @@ public class HTTPSession { } } + /// Apply `theme` to `selector` over a `duration`. + /// PUT /themes/{selector} public func applyTheme(_ selector: String, theme: String, duration: Float, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { let body = ThemeRequest(theme: theme, duration: duration) let request = HTTPRequest(baseURL: baseURL, path: "themes/\(selector)", method: .put, headers: ["Content-Type": "application/json"], body: body, expectedStatusCodes: [200, 207]) @@ -100,17 +115,26 @@ public class HTTPSession { setLightsState(selector, power: powerOn, color: color, duration: duration, completionHandler: completionHandler) } - // MARK: Internal Utilities + // MARK: Private Utils + /// Performs an `HTTPRequest` with the given parameters and will complete with the an `HTTPResponse`. private 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) - completion(HTTPResponse(data: data, response: response, error: parsedError)) + let wrapped = HTTPResponse(data: data, response: response, error: parsedError) + completion(wrapped) + if self.log { + print(wrapped) + } }) operationQueue.operations.first?.addDependency(operation) 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/Requests/HTTPRequest.swift b/Source/Requests/HTTPRequest.swift index 284b0dc..8a15abd 100644 --- a/Source/Requests/HTTPRequest.swift +++ b/Source/Requests/HTTPRequest.swift @@ -9,7 +9,7 @@ import Foundation private let encoder = JSONEncoder() -public struct HTTPRequest { +public struct HTTPRequest: CustomStringConvertible { public enum Method: String { case get = "GET" @@ -23,6 +23,14 @@ public struct HTTPRequest { 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 @@ -33,11 +41,6 @@ public struct HTTPRequest { } func toURLRequest() -> URLRequest { - var url = baseURL - if let path = path { - url.appendPathComponent(path) - } - var request = URLRequest(url: url) request.httpMethod = method.rawValue @@ -53,6 +56,20 @@ public struct HTTPRequest { 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/StateRequest.swift b/Source/Requests/StateRequest.swift index e1db0da..51366d5 100644 --- a/Source/Requests/StateRequest.swift +++ b/Source/Requests/StateRequest.swift @@ -8,8 +8,17 @@ import Foundation struct StateRequest: Encodable { - let power: Bool? + enum Power: String, Encodable { + case on, off + } + let power: Power? let color: String? let brightness: Double? let duration: Float } + +extension Bool { + var asPower: StateRequest.Power { + return self ? .on : .off + } +} diff --git a/Source/Responses/HTTPResponse.swift b/Source/Responses/HTTPResponse.swift index 96c3b89..3818af2 100644 --- a/Source/Responses/HTTPResponse.swift +++ b/Source/Responses/HTTPResponse.swift @@ -9,13 +9,15 @@ import Foundation private let decoder = JSONDecoder() -struct HTTPResponse { +struct HTTPResponse: CustomStringConvertible { + let data: Data? let body: T? let response: URLResponse? let error: Error? init(data: Data?, response: URLResponse?, error: Error?) { + self.data = data if let data = data { self.body = try? decoder.decode(T.self, from: data) } else { @@ -24,6 +26,22 @@ struct HTTPResponse { self.response = response self.error = error } + + 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 { } From 2c32746195b6ec2e468404fee5accd2cb752bb0b Mon Sep 17 00:00:00 2001 From: megan-lifx <40746004+megan-lifx@users.noreply.github.com> Date: Tue, 9 Oct 2018 14:39:02 +1100 Subject: [PATCH 11/21] Activate scene duration optional (#8) --- Source/HTTPSession.swift | 5 +++-- Source/LightTarget.swift | 2 +- Source/Requests/SceneRequest.swift | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Source/HTTPSession.swift b/Source/HTTPSession.swift index 45239ac..39a62db 100644 --- a/Source/HTTPSession.swift +++ b/Source/HTTPSession.swift @@ -60,6 +60,7 @@ public class HTTPSession { 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)) { @@ -70,9 +71,9 @@ public class HTTPSession { } } - /// Activates a scene over a `duration`. + /// 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, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { + public func setScenesActivate(_ selector: String, duration: Float? = nil, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { let body = SceneRequest(duration: duration) let request = HTTPRequest(baseURL: baseURL, path: "scenes/\(selector)/activate", method: .put, headers: ["Content-Type": "application/json"], body: body, expectedStatusCodes: [200, 207]) diff --git a/Source/LightTarget.swift b/Source/LightTarget.swift index 4b424c4..d46397e 100644 --- a/Source/LightTarget.swift +++ b/Source/LightTarget.swift @@ -215,7 +215,7 @@ public class LightTarget { } 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) if error != nil { diff --git a/Source/Requests/SceneRequest.swift b/Source/Requests/SceneRequest.swift index 1ff7088..e64ebaf 100644 --- a/Source/Requests/SceneRequest.swift +++ b/Source/Requests/SceneRequest.swift @@ -8,5 +8,5 @@ import Foundation struct SceneRequest: Encodable { - let duration: Float + let duration: Float? } From ed761e364ccf2d8faba4322fe57b1639d43389fa Mon Sep 17 00:00:00 2001 From: Alex Stonehouse Date: Tue, 16 Oct 2018 10:09:38 +1100 Subject: [PATCH 12/21] Feature/state tracking (#9) * Added tracking of state changes When state-change requests are made, we now track which properties are in-flight and dirty, and do not override those properties with new state unless the fetch request was made after the state request had completed. * Made some models Codable * Added toggle and fix for coding Fixed issue with decoding scenes from JSON. Also, added toggle ability, letting you toggle power state without knowing the current value beforehand. --- .gitignore | 1 + Source/Client.swift | 103 +++++++++++++--------- Source/HTTPSession.swift | 41 +++------ Source/LightTarget.swift | 52 ++++++++--- Source/Requests/HTTPRequest.swift | 1 + Source/Requests/TogglePowerRequest.swift | 12 +++ Source/Responses/Light.swift | 95 ++++++++++++++++++-- Source/Responses/ProductInformation.swift | 18 +++- Source/Responses/Scene.swift | 2 +- Source/Responses/State.swift | 11 ++- Source/Responses/Theme.swift | 23 ----- 11 files changed, 242 insertions(+), 117 deletions(-) create mode 100644 Source/Requests/TogglePowerRequest.swift delete mode 100644 Source/Responses/Theme.swift 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/Source/Client.swift b/Source/Client.swift index c5aa2fb..3a7cfe2 100644 --- a/Source/Client.swift +++ b/Source/Client.swift @@ -9,18 +9,16 @@ public class Client { public let session: HTTPSession public private(set) var lights: [Light] public private(set) var scenes: [Scene] - public private(set) var themes: [Theme] private var observers: [ClientObserver] - public convenience init(accessToken: String, lights: [Light]? = nil, scenes: [Scene]? = nil, themes: [Theme]? = nil) { - self.init(session: HTTPSession(accessToken: accessToken), lights: lights, scenes: scenes, themes: themes) + 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, themes: [Theme]? = nil) { + public init(session: HTTPSession, lights: [Light]? = nil, scenes: [Scene]? = nil) { self.session = session self.lights = lights ?? [] self.scenes = scenes ?? [] - self.themes = themes ?? [] observers = [] } @@ -50,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 @@ -84,25 +104,25 @@ public class Client { completionHandler?(nil) } } - - public func fetchThemes(completionHandler: ((_ error: Error?) -> Void)? = nil) { - session.curatedThemes { [weak self] (request, response, themes, error) in - if error != nil { - completionHandler?(error) - return - } - - self?.themes = themes - - completionHandler?(nil) - } - } public func allLightTarget() -> LightTarget { 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)) } @@ -123,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 { diff --git a/Source/HTTPSession.swift b/Source/HTTPSession.swift index 39a62db..a154c66 100644 --- a/Source/HTTPSession.swift +++ b/Source/HTTPSession.swift @@ -26,18 +26,19 @@ public class HTTPSession { // MARK: - Lifecycle - 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, log: Bool = false) { + 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 + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData session = URLSession(configuration: configuration) operationQueue = OperationQueue() - operationQueue.maxConcurrentOperationCount = 1 + operationQueue.maxConcurrentOperationCount = maxRequests } /// Lists lights limited by `selector`. @@ -61,6 +62,15 @@ public class HTTPSession { } } + 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)) { @@ -82,28 +92,6 @@ public class HTTPSession { } } - /// List curated themes. - /// GET https://cloud.lifx.com/themes/v1/curated - public func curatedThemes(_ completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ themes: [Theme], _ error: Error?) -> Void)) { - guard let curatedThemesURL = URL(string: "https://cloud.lifx.com/themes/v1/curated") else { return } - let request = HTTPRequest(baseURL: curatedThemesURL, path: nil) - - perform(request: request) { (response: HTTPResponse<[Theme]>) in - completionHandler(request.toURLRequest(), response.response, response.body ?? [], response.error) - } - } - - /// Apply `theme` to `selector` over a `duration`. - /// PUT /themes/{selector} - public func applyTheme(_ selector: String, theme: String, duration: Float, completionHandler: @escaping ((_ request: URLRequest, _ response: URLResponse?, _ results: [Result], _ error: Error?) -> Void)) { - let body = ThemeRequest(theme: theme, duration: duration) - let request = HTTPRequest(baseURL: baseURL, path: "themes/\(selector)", 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) - } - } - // MARK: - Deprecated @available(*, deprecated, message: "Use `setLightsState:power:color:brightness:duration:completionHandler:` instead.") @@ -119,7 +107,7 @@ public class HTTPSession { // MARK: Private Utils /// Performs an `HTTPRequest` with the given parameters and will complete with the an `HTTPResponse`. - private func perform(request: HTTPRequest, completion: @escaping (HTTPResponse) -> Void) { + func perform(request: HTTPRequest, completion: @escaping (HTTPResponse) -> Void) { if log { print(request) } @@ -131,7 +119,6 @@ public class HTTPSession { print(wrapped) } }) - operationQueue.operations.first?.addDependency(operation) operationQueue.addOperation(operation) } diff --git a/Source/LightTarget.swift b/Source/LightTarget.swift index d46397e..2d4d498 100644 --- a/Source/LightTarget.swift +++ b/Source/LightTarget.swift @@ -117,10 +117,10 @@ public class LightTarget { 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 +129,30 @@ public class LightTarget { completionHandler?(results, error) } } + + public func togglePower(_ duration: Float = LightTarget.defaultDuration, completionHandler: ((_ results: [Result], _ error: Error?) -> Void)? = nil) { + let oldPower = self.power + 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 + } + var newLights = self.lightsByDeterminingConnectivityWithResults(self.lights, results: results, removingInFlightProperties: [.toggle]) + if error != nil { + newLights = newLights.map({ $0.lightWithProperties(oldPower) }) + } + 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 +164,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) }) } @@ -175,10 +192,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) }) } @@ -208,7 +225,7 @@ public class LightTarget { 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 } @@ -217,7 +234,7 @@ public class LightTarget { client.updateLights(newLights) 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 }) { @@ -241,15 +258,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) + 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) } } } diff --git a/Source/Requests/HTTPRequest.swift b/Source/Requests/HTTPRequest.swift index 8a15abd..e4b3f1b 100644 --- a/Source/Requests/HTTPRequest.swift +++ b/Source/Requests/HTTPRequest.swift @@ -14,6 +14,7 @@ public struct HTTPRequest: CustomStringConvertible { public enum Method: String { case get = "GET" case put = "PUT" + case post = "POST" } let baseURL: URL 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/Responses/Light.swift b/Source/Responses/Light.swift index 4903ccf..0b941f0 100644 --- a/Source/Responses/Light.swift +++ b/Source/Responses/Light.swift @@ -5,7 +5,16 @@ import Foundation -public struct Light: Decodable, Equatable, CustomStringConvertible { +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 @@ -17,12 +26,17 @@ public struct Light: Decodable, Equatable, CustomStringConvertible { 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) { + 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 @@ -32,6 +46,8 @@ public struct Light: Decodable, Equatable, CustomStringConvertible { self.connected = connected self.group = group self.location = location + self.inFlightProperties = inFlightProperties + self.dirtyProperties = dirtyProperties } public init(from decoder: Decoder) throws { @@ -46,6 +62,22 @@ public struct Light: Decodable, Equatable, CustomStringConvertible { 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 { @@ -59,27 +91,70 @@ public struct Light: Decodable, Equatable, CustomStringConvertible { 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) -> 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) + 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.productInfo?.capabilities?.hasColor ?? false + return self.product?.capabilities?.hasColor ?? false } public var hasIR: Bool { - return self.productInfo?.capabilities?.hasIR ?? false + return self.product?.capabilities?.hasIR ?? false } public var hasMultiZone: Bool { - return self.productInfo?.capabilities?.hasMulitiZone ?? false + return self.product?.capabilities?.hasMulitiZone ?? false } // MARK: Printable @@ -89,7 +164,7 @@ public struct Light: Decodable, Equatable, CustomStringConvertible { } } -public func ==(lhs: Light, rhs: Light) -> Bool { +public func == (lhs: Light, rhs: Light) -> Bool { return lhs.id == rhs.id && lhs.power == rhs.power && lhs.brightness == rhs.brightness && @@ -97,5 +172,7 @@ public func ==(lhs: Light, rhs: Light) -> Bool { lhs.label == rhs.label && lhs.connected == rhs.connected && lhs.group == rhs.group && - lhs.location == rhs.location + lhs.location == rhs.location && + lhs.inFlightProperties == rhs.inFlightProperties && + lhs.dirtyProperties == rhs.dirtyProperties } diff --git a/Source/Responses/ProductInformation.swift b/Source/Responses/ProductInformation.swift index 1e45ca4..c4195bd 100644 --- a/Source/Responses/ProductInformation.swift +++ b/Source/Responses/ProductInformation.swift @@ -6,7 +6,7 @@ import Foundation -public struct ProductInformation: Decodable { +public struct ProductInformation: Codable { public let productName: String public let manufacturer: String public let capabilities: Capabilities? @@ -17,6 +17,13 @@ public struct ProductInformation: Decodable { 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))" @@ -29,7 +36,7 @@ public struct ProductInformation: Decodable { } } -public struct Capabilities: Decodable { +public struct Capabilities: Codable { public let hasColor: Bool public let hasIR: Bool public let hasMulitiZone: Bool @@ -40,6 +47,13 @@ public struct Capabilities: Decodable { hasIR = try container.decode(Bool.self, forKey: .hasIR) hasMulitiZone = try container.decode(Bool.self, forKey: .hasMultiZone) } + + 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(hasMulitiZone, forKey: .hasMultiZone) + } var description: String { return "Color - \(hasColor), Infra-red \(hasIR), Multiple zones - \(hasMulitiZone)" diff --git a/Source/Responses/Scene.swift b/Source/Responses/Scene.swift index 77b5f86..d9d340a 100644 --- a/Source/Responses/Scene.swift +++ b/Source/Responses/Scene.swift @@ -5,7 +5,7 @@ import Foundation -public struct Scene: Decodable, 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 index 55fbada..e8e93ae 100644 --- a/Source/Responses/State.swift +++ b/Source/Responses/State.swift @@ -5,7 +5,7 @@ import Foundation -public struct State: Decodable, Equatable { +public struct State: Codable, Equatable { public let selector: LightTargetSelector public let brightness: Double? public let color: Color? @@ -26,6 +26,15 @@ public struct State: Decodable, Equatable { color = try container.decode(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 diff --git a/Source/Responses/Theme.swift b/Source/Responses/Theme.swift deleted file mode 100644 index a3b083d..0000000 --- a/Source/Responses/Theme.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Theme.swift -// LIFXHTTPKit -// -// Created by Alexander Stonehouse on 9/8/18. -// Copyright © 2018 Tate Johnson. All rights reserved. -// - -import Foundation - -public struct Theme: Equatable, Codable { - public let uuid: String - public let title: String - public let invocation: String? - public let analytics: String - public let image_url: String - public let order: Int - public let colors: [Color] -} - -public func == (lhs: Theme, rhs: Theme) -> Bool { - return lhs.uuid == rhs.uuid -} From 138d0a482297b3efd7d989d8f6d0445d60b12623 Mon Sep 17 00:00:00 2001 From: Alex Stonehouse Date: Mon, 21 Jan 2019 16:07:29 +1100 Subject: [PATCH 13/21] Added optional brightness parameter to color --- Source/Responses/Color.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Source/Responses/Color.swift b/Source/Responses/Color.swift index 78c33c2..3268b24 100644 --- a/Source/Responses/Color.swift +++ b/Source/Responses/Color.swift @@ -12,11 +12,13 @@ public struct Color: Equatable, Codable, CustomStringConvertible { 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) { @@ -25,12 +27,14 @@ public struct Color: Equatable, Codable, CustomStringConvertible { 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 } From c754f2f44b9456a4d25e8484dd0c681400ffdce3 Mon Sep 17 00:00:00 2001 From: megan-lifx <40746004+megan-lifx@users.noreply.github.com> Date: Thu, 10 Oct 2019 16:56:40 +1100 Subject: [PATCH 14/21] Adding variable color temp capability to product (#10) --- Source/LightTarget.swift | 4 ++++ Source/Responses/Light.swift | 4 ++++ Source/Responses/ProductInformation.swift | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/Source/LightTarget.swift b/Source/LightTarget.swift index 2d4d498..4ddb68a 100644 --- a/Source/LightTarget.swift +++ b/Source/LightTarget.swift @@ -445,4 +445,8 @@ public class LightTarget { 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/Responses/Light.swift b/Source/Responses/Light.swift index 0b941f0..67a5adc 100644 --- a/Source/Responses/Light.swift +++ b/Source/Responses/Light.swift @@ -156,6 +156,10 @@ public struct Light: Codable, Equatable, CustomStringConvertible { public var hasMultiZone: Bool { return self.product?.capabilities?.hasMulitiZone ?? false } + + public var hasVariableColorTemp: Bool { + return self.product?.capabilities?.hasVariableColorTemp ?? false + } // MARK: Printable diff --git a/Source/Responses/ProductInformation.swift b/Source/Responses/ProductInformation.swift index c4195bd..88850b2 100644 --- a/Source/Responses/ProductInformation.swift +++ b/Source/Responses/ProductInformation.swift @@ -40,12 +40,14 @@ public struct Capabilities: Codable { public let hasColor: Bool public let hasIR: 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) hasMulitiZone = try container.decode(Bool.self, forKey: .hasMultiZone) + hasVariableColorTemp = try container.decode(Bool.self, forKey: .hasVariableColorTemp) } public func encode(to encoder: Encoder) throws { @@ -53,6 +55,7 @@ public struct Capabilities: Codable { try container.encode(hasColor, forKey: .hasColor) try container.encode(hasIR, forKey: .hasIR) try container.encode(hasMulitiZone, forKey: .hasMultiZone) + try container.encode(hasVariableColorTemp, forKey: .hasVariableColorTemp) } var description: String { @@ -63,6 +66,7 @@ public struct Capabilities: Codable { case hasColor = "has_color" case hasIR = "has_ir" case hasMultiZone = "has_multizone" + case hasVariableColorTemp = "has_variable_color_temp" } } From 93d481dc55a73aecbb0b4375e24a13a73924e974 Mon Sep 17 00:00:00 2001 From: Alex Stonehouse Date: Wed, 13 Nov 2019 15:48:53 +1100 Subject: [PATCH 15/21] Fixed decoding on Scene state There was an error where Scene state might not be able to be decoded. Also made sure that decoding errors now get passed up rather than throwing it away. --- Source/Responses/HTTPResponse.swift | 10 ++++++++-- Source/Responses/State.swift | 6 +++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Source/Responses/HTTPResponse.swift b/Source/Responses/HTTPResponse.swift index 3818af2..ede26fc 100644 --- a/Source/Responses/HTTPResponse.swift +++ b/Source/Responses/HTTPResponse.swift @@ -17,14 +17,20 @@ struct HTTPResponse: CustomStringConvertible { let error: Error? init(data: Data?, response: URLResponse?, error: Error?) { + var err: Error? = error self.data = data if let data = data { - self.body = try? decoder.decode(T.self, from: 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 = error + self.error = err } var description: String { diff --git a/Source/Responses/State.swift b/Source/Responses/State.swift index e8e93ae..10088fe 100644 --- a/Source/Responses/State.swift +++ b/Source/Responses/State.swift @@ -20,10 +20,10 @@ public struct State: Codable, Equatable { } self.selector = selector - let on = try container.decode(String.self, forKey: .power) + let on = try container.decodeIfPresent(String.self, forKey: .power) power = on == "on" - brightness = try container.decode(Double.self, forKey: .brightness) - color = try container.decode(Color.self, forKey: .color) + brightness = try container.decodeIfPresent(Double.self, forKey: .brightness) + color = try container.decodeIfPresent(Color.self, forKey: .color) } public func encode(to encoder: Encoder) throws { From f7180933bda116ab1510c7fedd4b277d5ea6a7a2 Mon Sep 17 00:00:00 2001 From: Alex Stonehouse Date: Fri, 27 Mar 2020 10:30:55 +1100 Subject: [PATCH 16/21] Added convenience fetch method on LightTarget --- Source/LightTarget.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Source/LightTarget.swift b/Source/LightTarget.swift index 4ddb68a..cd7edb0 100644 --- a/Source/LightTarget.swift +++ b/Source/LightTarget.swift @@ -114,6 +114,10 @@ public class LightTarget { } // 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 From dde6c265da28e459eceb22f794603ceea513b8f8 Mon Sep 17 00:00:00 2001 From: Alex Stonehouse Date: Mon, 30 Mar 2020 10:42:53 +1100 Subject: [PATCH 17/21] Fixed issue with deriveBrightness that was using the wrong denominator if some lights were disconnected --- Source/Client.swift | 4 ++-- Source/LightTarget.swift | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Source/Client.swift b/Source/Client.swift index 3a7cfe2..18dd189 100644 --- a/Source/Client.swift +++ b/Source/Client.swift @@ -7,8 +7,8 @@ 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) { diff --git a/Source/LightTarget.swift b/Source/LightTarget.swift index cd7edb0..aa2d4e4 100644 --- a/Source/LightTarget.swift +++ b/Source/LightTarget.swift @@ -361,22 +361,22 @@ public class LightTarget { } 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) @@ -388,10 +388,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) From 0e650a8328294e025509ed3568c4f657adc83058 Mon Sep 17 00:00:00 2001 From: Nick Jones Date: Fri, 5 Jun 2020 08:59:45 +1000 Subject: [PATCH 18/21] Switch to async API calls for those that support it. The async API is much faster, but has the downside that it does not indicate when the request times out - it can distinguish between offline and accepted for processing requests though. We've recently switched the Google and Amazon integrations to this API. --- Source/HTTPSession.swift | 4 ++-- Source/LightTarget.swift | 2 +- Source/Requests/SceneRequest.swift | 1 + Source/Requests/StateRequest.swift | 1 + Source/Responses/Result.swift | 1 + 5 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Source/HTTPSession.swift b/Source/HTTPSession.swift index a154c66..c33c66d 100644 --- a/Source/HTTPSession.swift +++ b/Source/HTTPSession.swift @@ -54,7 +54,7 @@ public class HTTPSession { /// 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)) { - let body = StateRequest(power: power?.asPower, color: color, brightness: brightness, duration: duration) + 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 @@ -84,7 +84,7 @@ public class HTTPSession { /// 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) + 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 diff --git a/Source/LightTarget.swift b/Source/LightTarget.swift index aa2d4e4..5021163 100644 --- a/Source/LightTarget.swift +++ b/Source/LightTarget.swift @@ -275,7 +275,7 @@ public class LightTarget { for result in results { if result.id == light.id { switch result.status { - case .OK: + case .OK, .Async: return light.lightWithProperties(connected: true, inFlightProperties: newInFlightProperties, dirtyProperties: dirtyProperties) case .TimedOut, .Offline: // If failed, use new inFlight which removes the inFlight properties and use old dirtyProperties so that that property is not considered dirty diff --git a/Source/Requests/SceneRequest.swift b/Source/Requests/SceneRequest.swift index e64ebaf..f8032c0 100644 --- a/Source/Requests/SceneRequest.swift +++ b/Source/Requests/SceneRequest.swift @@ -9,4 +9,5 @@ import Foundation struct SceneRequest: Encodable { let duration: Float? + let async: Bool? } diff --git a/Source/Requests/StateRequest.swift b/Source/Requests/StateRequest.swift index 51366d5..5e8500c 100644 --- a/Source/Requests/StateRequest.swift +++ b/Source/Requests/StateRequest.swift @@ -15,6 +15,7 @@ struct StateRequest: Encodable { let color: String? let brightness: Double? let duration: Float + let async: Bool? } extension Bool { diff --git a/Source/Responses/Result.swift b/Source/Responses/Result.swift index 42c80b8..9838fe1 100644 --- a/Source/Responses/Result.swift +++ b/Source/Responses/Result.swift @@ -12,6 +12,7 @@ public struct Results: Decodable { public struct Result: Decodable, Equatable, CustomStringConvertible { public enum Status: String, Codable { case OK = "ok" + case Async = "async" case TimedOut = "timed_out" case Offline = "offline" } From 59105e9d676cf72348e83dc8df3f1efe3242d91b Mon Sep 17 00:00:00 2001 From: Jason Chan <59811105+jasonlifx@users.noreply.github.com> Date: Mon, 13 Jul 2020 15:19:27 +1000 Subject: [PATCH 19/21] Feature/toggle power result (#14) * Add power state to result + update togglePower logic + code cleanup --- Source/Client.swift | 2 +- Source/LightTarget.swift | 27 +++++++++++++-------------- Source/Responses/Result.swift | 7 ++++++- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/Source/Client.swift b/Source/Client.swift index 18dd189..a8917b1 100644 --- a/Source/Client.swift +++ b/Source/Client.swift @@ -177,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/LightTarget.swift b/Source/LightTarget.swift index 5021163..7180879 100644 --- a/Source/LightTarget.swift +++ b/Source/LightTarget.swift @@ -108,11 +108,6 @@ public class LightTarget { } } - public func toLights() -> [Light] { - print("`toLights` is deprecated and will be removed in a future version. Use `lights` instead.") - return lights - } - // MARK: Lighting Operations public func fetch() { @@ -135,17 +130,21 @@ public class LightTarget { } public func togglePower(_ duration: Float = LightTarget.defaultDuration, completionHandler: ((_ results: [Result], _ error: Error?) -> Void)? = nil) { - let oldPower = self.power 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 } - var newLights = self.lightsByDeterminingConnectivityWithResults(self.lights, results: results, removingInFlightProperties: [.toggle]) - if error != nil { - newLights = newLights.map({ $0.lightWithProperties(oldPower) }) - } + 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) } @@ -217,14 +216,14 @@ 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 @@ -241,7 +240,7 @@ public class LightTarget { 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 { @@ -417,7 +416,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 "" diff --git a/Source/Responses/Result.swift b/Source/Responses/Result.swift index 9838fe1..429e12c 100644 --- a/Source/Responses/Result.swift +++ b/Source/Responses/Result.swift @@ -16,9 +16,14 @@ public struct Result: Decodable, Equatable, CustomStringConvertible { 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 From 8f54f9c4f53738f4c462f5aba259788ee6a10741 Mon Sep 17 00:00:00 2001 From: Jason Chan <59811105+jasonlifx@users.noreply.github.com> Date: Wed, 6 Jan 2021 16:32:54 +1100 Subject: [PATCH 20/21] Feature/clean cherrypicked (#15) * Add support for clean cycle * Add HEV capability support --- Source/HTTPSession.swift | 11 +++++++++++ Source/LightTarget.swift | 6 +++++- Source/Requests/CleanCycleRequest.swift | 19 +++++++++++++++++++ Source/Responses/Light.swift | 4 ++++ Source/Responses/ProductInformation.swift | 4 ++++ 5 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 Source/Requests/CleanCycleRequest.swift diff --git a/Source/HTTPSession.swift b/Source/HTTPSession.swift index c33c66d..85f3727 100644 --- a/Source/HTTPSession.swift +++ b/Source/HTTPSession.swift @@ -91,6 +91,17 @@ public class HTTPSession { 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 diff --git a/Source/LightTarget.swift b/Source/LightTarget.swift index 7180879..87ca424 100644 --- a/Source/LightTarget.swift +++ b/Source/LightTarget.swift @@ -444,7 +444,11 @@ 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) } 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/Responses/Light.swift b/Source/Responses/Light.swift index 67a5adc..0c99e1c 100644 --- a/Source/Responses/Light.swift +++ b/Source/Responses/Light.swift @@ -152,6 +152,10 @@ public struct Light: Codable, Equatable, CustomStringConvertible { 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 diff --git a/Source/Responses/ProductInformation.swift b/Source/Responses/ProductInformation.swift index 88850b2..5921b0e 100644 --- a/Source/Responses/ProductInformation.swift +++ b/Source/Responses/ProductInformation.swift @@ -39,6 +39,7 @@ public struct ProductInformation: Codable { public struct Capabilities: Codable { public let hasColor: Bool public let hasIR: Bool + public let hasHEV: Bool public let hasMulitiZone: Bool public let hasVariableColorTemp: Bool @@ -46,6 +47,7 @@ public struct Capabilities: Codable { 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) } @@ -54,6 +56,7 @@ public struct Capabilities: Codable { 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) } @@ -65,6 +68,7 @@ public struct Capabilities: Codable { 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" } From 7bfb3801da527dc47a13de1171b82229e3e35714 Mon Sep 17 00:00:00 2001 From: Jason Chan Date: Tue, 12 Jan 2021 10:19:50 +1100 Subject: [PATCH 21/21] Add filter function as a param for HEV --- Source/LightTarget.swift | 66 +++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/Source/LightTarget.swift b/Source/LightTarget.swift index 87ca424..93bd281 100644 --- a/Source/LightTarget.swift +++ b/Source/LightTarget.swift @@ -79,34 +79,44 @@ 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 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