From ece19dd362b838ba13ffc97b3ebaa7945fef8a01 Mon Sep 17 00:00:00 2001 From: Zhigang Fang Date: Mon, 1 May 2017 11:00:30 +0800 Subject: [PATCH] Initial commit --- .swift-version | 1 + OSCKit.podspec | 17 ++++++ Source/Command.swift | 109 +++++++++++++++++++++++++++++++++++++ Source/DeviceInfo.swift | 38 +++++++++++++ Source/Image.swift | 58 ++++++++++++++++++++ Source/LivePreview.swift | 88 ++++++++++++++++++++++++++++++ Source/MediaItems.swift | 71 ++++++++++++++++++++++++ Source/OSCKit-Swift.h | 19 +++++++ Source/OSCKit.swift | 44 +++++++++++++++ Source/OptionalThrow.swift | 30 ++++++++++ Source/Options.swift | 34 ++++++++++++ Source/Request.swift | 68 +++++++++++++++++++++++ Source/Session.swift | 67 +++++++++++++++++++++++ Source/Status.swift | 28 ++++++++++ Source/Video.swift | 63 +++++++++++++++++++++ 15 files changed, 735 insertions(+) create mode 100644 .swift-version create mode 100644 OSCKit.podspec create mode 100644 Source/Command.swift create mode 100644 Source/DeviceInfo.swift create mode 100644 Source/Image.swift create mode 100644 Source/LivePreview.swift create mode 100644 Source/MediaItems.swift create mode 100644 Source/OSCKit-Swift.h create mode 100644 Source/OSCKit.swift create mode 100644 Source/OptionalThrow.swift create mode 100644 Source/Options.swift create mode 100644 Source/Request.swift create mode 100644 Source/Session.swift create mode 100644 Source/Status.swift create mode 100644 Source/Video.swift diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..9f55b2c --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +3.0 diff --git a/OSCKit.podspec b/OSCKit.podspec new file mode 100644 index 0000000..8273941 --- /dev/null +++ b/OSCKit.podspec @@ -0,0 +1,17 @@ +Pod::Spec.new do |s| + + s.name = "OSCKit" + s.version = "0.0.1" + s.summary = "Richo OSCKit" + s.description = "Richo OSCKit with Promise" + s.homepage = "https://theta360.com/" + s.license = "MIT" + s.author = { "Zhigang Fang" => "zhigang1992@gmail.com" } + s.platform = :ios, "9.0" + s.source = { :git => "https://github.com/tappollo/OSCKit.git" } + s.source_files = "Source/*.swift" + s.frameworks = "SystemConfiguration" + s.dependency 'SwiftyyJSON' + s.dependency 'PromiseKit' + s.dependency 'AwaitKit' +end diff --git a/Source/Command.swift b/Source/Command.swift new file mode 100644 index 0000000..c773fdf --- /dev/null +++ b/Source/Command.swift @@ -0,0 +1,109 @@ +// +// Command.swift +// ThreeSixtyCamera +// +// Created by Zhigang Fang on 4/18/17. +// Copyright © 2017 Tappollo Inc. All rights reserved. +// + +import Foundation +import SwiftyyJSON + +// swiftlint:disable identifier_name +// We want to keep it as close to API as possible +enum Command { + case startSession + case updateSession(sessionId: String) + case closeSession(sessionId: String) + case _finishWlan + case takePicture(sessionId: String) + case _startCapture(sessionId: String, mode: VideoCaptureMode) + case _stopCapture(sessionId: String) + case listImages + case _listAll(entryCount: Int, detail: Bool) + case delete + case getImage(fileUri: String, _type: DownloadType) + case _getVideo(fileUri: String, _type: DownloadType) + case _getLivePreview(sessionId: String) + case getMetadata + case getOptions + case setOptions(options: [Option], sessionId: String) + case _getMySetting + case _setMySetting + case _stopSelfTimer +} +// swiftlint:enable identifier_name + +extension Command { + var name: String { + switch self { + case .startSession: return "camera.startSession" + case .updateSession: return "camera.updateSession" + case .closeSession: return "camera.closeSession" + case ._finishWlan: return "camera._finishWlan" + case .takePicture: return "camera.takePicture" + case ._startCapture: return "camera._startCapture" + case ._stopCapture: return "camera._stopCapture" + case .listImages: return "camera.listImages" + case ._listAll: return "camera._listAll" + case .delete: return "camera.delete" + case .getImage: return "camera.getImage" + case ._getVideo: return "camera._getVideo" + case ._getLivePreview: return "camera._getLivePreview" + case .getMetadata: return "camera.getMetadata" + case .getOptions: return "camera.getOptions" + case .setOptions: return "camera.setOptions" + case ._getMySetting: return "camera._getMySetting" + case ._setMySetting: return "camera._setMySetting" + case ._stopSelfTimer: return "camera._stopSelfTimer" + } + } + + var defaultJSON: JSON { + return ["name": self.name] + } + + func with(params: [String: Any]) -> JSON { + return [ + "name": self.name, + "parameters": params + ] + } + + var json: JSON { + switch self { + case .updateSession(sessionId: let id): return with(params: ["sessionId": id]) + case .takePicture(sessionId: let id): return with(params: ["sessionId": id]) + case let .getImage(fileUri: fileUri, _type: _type): + return with(params: [ + "fileUri": fileUri, + "_type": _type.rawValue + ]) + case let ._getVideo(fileUri: fileUri, _type: _type): + return with(params: [ + "fileUri": fileUri, + "_type": _type.rawValue + ]) + case let ._startCapture(sessionId: id, mode: mode): + return with(params: [ + "sessionId": id, + "mode": mode.rawValue + ]) + case ._stopCapture(sessionId: let id): return with(params: ["sessionId": id]) + case let ._listAll(entryCount: count, detail: detail): + return with(params: [ + "entryCount": count, + "detail": detail + ]) + case let .setOptions(options: options, sessionId: id): + var json: JSON = [:] + options.forEach({ json[$0.key] = $0.value }) + return with(params: [ + "sessionId": id, + "options": json.value ?? NSNull() + ]) + case ._getLivePreview(sessionId: let id): return with(params: ["sessionId": id]) + default: return defaultJSON + } + } +} diff --git a/Source/DeviceInfo.swift b/Source/DeviceInfo.swift new file mode 100644 index 0000000..d2b0cbc --- /dev/null +++ b/Source/DeviceInfo.swift @@ -0,0 +1,38 @@ +// +// DeviceInfo.swift +// ThreeSixtyCamera +// +// Created by Zhigang Fang on 4/18/17. +// Copyright © 2017 Tappollo Inc. All rights reserved. +// + +import Foundation +import SwiftyyJSON +import PromiseKit +import AwaitKit + +public struct DeviceInfo { + public let model: String + public let serial: String + public let battery: Double +} + +extension OSCKit { + public var deviceInfo: Promise { + return async { + let info = try await(self.info) + let state = try await(self.state) + + return DeviceInfo( + model: try info["model"].string !! SDKError.unableToParse(info), + serial: try info["serialNumber"].string !! SDKError.unableToParse(info), + battery: try state["state"]["batteryLevel"].double !! SDKError.unableToParse(state) + ) + } + } + + public var info: Promise { return self.requestJSON(endPoint: .info) } + + public var state: Promise { return self.requestJSON(endPoint: .state) } + +} diff --git a/Source/Image.swift b/Source/Image.swift new file mode 100644 index 0000000..181b847 --- /dev/null +++ b/Source/Image.swift @@ -0,0 +1,58 @@ +// +// Image.swift +// ThreeSixtyCamera +// +// Created by Zhigang Fang on 4/18/17. +// Copyright © 2017 Tappollo Inc. All rights reserved. +// + +import Foundation +import SwiftyyJSON +import PromiseKit +import AwaitKit + +public enum DownloadType: String { + case thumbnail = "thumb" + case full = "full" +} + +extension OSCKit { + + public func getImage(url: String, type: DownloadType = .full) -> Promise { + return async { + let url = try await(self.getImageLocalURL(url: url, type: type)) + return try UIImage(contentsOfFile: url.path) !! SDKError.unableToFindImageAt(url) + } + } + + public func getImageLocalURL(url: String, type: DownloadType = .full) -> Promise { + return async { + let device = try await(self.deviceInfo) + // Adding serial key in the begining + // To prevent cache collision between different devices + let cacheKey = try (device.serial + url).addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) !! SDKError.unableToCreateVideoCacheKey + let cacheFolder = try NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first.map({ + URL(fileURLWithPath: $0) + }) !! SDKError.unableToFindCacheFolder + let fileURL = cacheFolder.appendingPathComponent(cacheKey) + if FileManager.default.fileExists(atPath: fileURL.path) { + return fileURL + } + let data = try await(self.requestData(command: .getImage(fileUri: url, _type: type))) + try data.write(to: fileURL) + return fileURL + } + } + + public func takePicture() -> Promise { + return async { + let session = try await(self.session) + try await(self.execute(command: .setOptions(options: [CaptureMode.image], sessionId: session.id))) + try await(self.execute(command: .setOptions(options: [FileFormat.smallImage], sessionId: session.id))) + let captureResponse = try await(self.execute(command: .takePicture(sessionId: session.id))) + let statusID = try captureResponse["id"].string !! SDKError.unableToParse(captureResponse) + let statusResponse = try await(self.waitForStatus(id: statusID)) + return try statusResponse["results"]["fileUri"].string !! SDKError.unableToParse(statusResponse) + } + } +} diff --git a/Source/LivePreview.swift b/Source/LivePreview.swift new file mode 100644 index 0000000..a545ffa --- /dev/null +++ b/Source/LivePreview.swift @@ -0,0 +1,88 @@ +// +// LivePreview.swift +// ThreeSixtyCamera +// +// Created by Zhigang Fang on 4/18/17. +// Copyright © 2017 Tappollo Inc. All rights reserved. +// + +import Foundation +import SwiftyyJSON +import PromiseKit +import AwaitKit + +final class LivePreview: NSObject, URLSessionDataDelegate { + static let shared = LivePreview() + + lazy var session: URLSession = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: nil) + + enum Status { + case stopped + case loading + case playing + } + + var status: Status = .stopped + private var receivedData: NSMutableData? + private var dataTask: URLSessionDataTask? + + var callback: (UIImage) -> Void = { _ in } + + private override init() {} + + func play(request: URLRequest) { + self.receivedData = NSMutableData() + self.dataTask?.cancel() + self.dataTask = self.session.dataTask(with: request) + self.dataTask?.resume() + self.status = .loading + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + if let imageData = receivedData , imageData.length > 0, + let receivedImage = UIImage(data: imageData as Data) { + if status == .loading { + status = .playing + } + DispatchQueue.main.async { + self.callback(receivedImage) + } + } + + receivedData = NSMutableData() + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + self.receivedData?.append(data) + } + + func stop() { + self.dataTask?.cancel() + self.dataTask = nil + self.receivedData = nil + } +} + + +extension OSCKit { + public func startLivePreview(callback: @escaping (UIImage) -> Void) { + async { + let session = try await(OSCKit.shared.session) + try await(self.execute(command: .setOptions(options: [CaptureMode.image], sessionId: session.id))) + DispatchQueue.main.async(execute: { + LivePreview.shared.stop() + LivePreview.shared.callback = callback + let json = Command._getLivePreview(sessionId: session.id).json + let request = self.assembleRequest(endPoint: .execute, params: json) + LivePreview.shared.play(request: request) + }) + } + } + + public func stopLivePreview() { + DispatchQueue.main.async { + LivePreview.shared.stop() + } + } +} diff --git a/Source/MediaItems.swift b/Source/MediaItems.swift new file mode 100644 index 0000000..cd2d019 --- /dev/null +++ b/Source/MediaItems.swift @@ -0,0 +1,71 @@ +// +// Items.swift +// ThreeSixtyCamera +// +// Created by Zhigang Fang on 4/18/17. +// Copyright © 2017 Tappollo Inc. All rights reserved. +// + +import Foundation +import SwiftyyJSON +import PromiseKit +import AwaitKit + +public struct MediaItem { + public enum Kind { + case image + case video + } + + public let name: String + public let url: String + + public let size: Int? + public let date: String? + public let width: Int? + public let height: Int? + + public var type: Kind { + if name.lowercased().hasSuffix(".mp4") { + return .video + } + return .image + } + + init (json: JSON) throws { + self.name = try json["name"].string !! OSCKit.SDKError.unableToParse(json) + self.url = try json["uri"].string !! OSCKit.SDKError.unableToParse(json) + self.size = json["size"].int + self.date = json["dateTimeZone"].string + self.width = json["width"].int + self.height = json["height"].int + } +} + +extension OSCKit { + public var listAllMediaItems: Promise<[MediaItem]> { + return async { + let all = try await(self.execute(command: ._listAll(entryCount: 100, detail: false))) + let entries = try all["results"]["entries"].array !! SDKError.unableToParse(all) + return try entries.map({try MediaItem(json: $0)}) + } + } + + public func getLatestMediaItem(timeout: TimeInterval = 0, withPredicate predicate: @escaping (MediaItem) -> Bool) -> Promise { + return async { + if timeout < 0 { + throw SDKError.fetchTimeout + } + let all = try await(self.execute(command: ._listAll(entryCount: 1, detail: true))) + let json = try all["results"]["entries"].array !! SDKError.unableToParse(all) + if let first = json.first { + let item = try MediaItem(json: first) + if predicate(item) { + return item + } + } + try await(after(interval: 2)) + return try await(self.getLatestMediaItem(timeout: timeout - 2, withPredicate: predicate)) + } + } +} diff --git a/Source/OSCKit-Swift.h b/Source/OSCKit-Swift.h new file mode 100644 index 0000000..91a10dc --- /dev/null +++ b/Source/OSCKit-Swift.h @@ -0,0 +1,19 @@ +// +// OSCKit-Swift.h +// OSCKit +// +// Created by Zhigang Fang on 4/18/17. +// Copyright © 2017 matrix. All rights reserved. +// + +#import + +//! Project version number for OSCKit. +FOUNDATION_EXPORT double OSCKitKVersionNumber; + +//! Project version string for OSCKit. +FOUNDATION_EXPORT const unsigned char OSCKitVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/Source/OSCKit.swift b/Source/OSCKit.swift new file mode 100644 index 0000000..720394d --- /dev/null +++ b/Source/OSCKit.swift @@ -0,0 +1,44 @@ +// +// OSCKit.swift +// ThreeSixtyCamera +// +// Created by Zhigang Fang on 4/17/17. +// Copyright © 2017 Tappollo Inc. All rights reserved. +// +import SwiftyyJSON +import SystemConfiguration.CaptiveNetwork + +public class OSCKit { + public static let shared = OSCKit() + + enum SDKError: Error { + case unableToParse(JSON) + case unableToFindImageAt(URL) + case fetchTimeout + case unableToFindCacheFolder + case unableToFindVideo + case unableToCreateVideoCacheKey + } + + private init() { } + + public func isConnectedToDeviceWiFi(withPrefix prefix: String) -> Bool { + return SSID.current?.hasPrefix(prefix) == true + } +} + +struct SSID { + static var current: String? { + if let interfaces = CNCopySupportedInterfaces() { + for i in 0..(optional: Optional, error: Error) throws -> T { + return try optional.someOrThrow(error) +} + +extension Optional { + func someOrThrow(_ error: Error) throws -> Wrapped { + if let value = self { + return value + } + throw error + } +} + +func const(value: T) -> (V) -> T { + return { _ in + value + } +} diff --git a/Source/Options.swift b/Source/Options.swift new file mode 100644 index 0000000..dedbd0f --- /dev/null +++ b/Source/Options.swift @@ -0,0 +1,34 @@ +// +// Options.swift +// ThreeSixtyCamera +// +// Created by Zhigang Fang on 4/18/17. +// Copyright © 2017 Tappollo Inc. All rights reserved. +// + +import Foundation +import SwiftyyJSON + +protocol Option { + var key: String { get } + var value: JSON { get } +} + +enum CaptureMode: String, Option { + case video = "_video" + case image = "image" + var key: String { return "captureMode" } + var value: JSON { return JSON(value: self.rawValue as NSObject) } +} + +struct FileFormat: Option { + + let type: String + let width: Int + let height: Int + + var key: String { return "fileFormat" } + var value: JSON { return ["type": type, "width": width, "height": height] } + + static let smallImage = FileFormat(type: "jpeg", width: 2048, height: 1024) +} diff --git a/Source/Request.swift b/Source/Request.swift new file mode 100644 index 0000000..308df66 --- /dev/null +++ b/Source/Request.swift @@ -0,0 +1,68 @@ +// +// Request.swift +// ThreeSixtyCamera +// +// Created by Zhigang Fang on 4/18/17. +// Copyright © 2017 Tappollo Inc. All rights reserved. +// + +import Foundation +import SwiftyyJSON +import PromiseKit +import AwaitKit + +enum Endpoint { + case info + case state + + case execute + case status + + var path: String { + switch self { + case .execute: return "/osc/commands/execute" + case .status: return "/osc/commands/status" + case .info: return "/osc/info" + case .state: return "/osc/state" + } + } + + var method: String { + switch self { + case .execute, .state, .status: return "POST" + case .info: return "GET" + } + } +} + +extension OSCKit { + func assembleRequest(endPoint: Endpoint = .execute, params json: JSON? = nil) -> URLRequest { + var request = URLRequest(url: URL(string: "http://192.168.1.1\(endPoint.path)")!) + request.httpMethod = endPoint.method + request.addValue("application/json;charset=utf-8", forHTTPHeaderField: "Content-Type") + if let json = json { + request.httpBody = json.encode() + } + return request + } + + func requestJSON(endPoint: Endpoint = .execute, params json: JSON? = nil) -> Promise { + var request = assembleRequest(endPoint: endPoint, params: json) + request.addValue("application/json", forHTTPHeaderField: "Accept") + return URLSession.shared.dataTask(with: request).then(execute: { data -> JSON in + let anyObject = try JSONSerialization.jsonObject(with: data, options: []) + return JSON(value: anyObject as? NSObject) + }) + } + + func requestData(command: Command) -> Promise { + return async { + let request = self.assembleRequest(params: command.json) + return try await(URLSession.shared.dataTask(with: request)) + } + } + + func execute(command: Command) -> Promise { + return self.requestJSON(endPoint: .execute, params: command.json) + } +} diff --git a/Source/Session.swift b/Source/Session.swift new file mode 100644 index 0000000..4bc5513 --- /dev/null +++ b/Source/Session.swift @@ -0,0 +1,67 @@ +// +// Session.swift +// ThreeSixtyCamera +// +// Created by Zhigang Fang on 4/18/17. +// Copyright © 2017 Tappollo Inc. All rights reserved. +// + +import Foundation +import SwiftyyJSON +import PromiseKit +import AwaitKit + +public struct Session { + let id: String + let expires: Date + + fileprivate static var currentSession: Session? + + init (json: JSON) throws { + self.id = try json["results"]["sessionId"].string !! OSCKit.SDKError.unableToParse(json) + let expire = try json["results"]["timeout"].int !! OSCKit.SDKError.unableToParse(json) + self.expires = Date().addingTimeInterval(TimeInterval(expire)) + } + + var isExpired: Bool { + return self.expires < Date() + } + + var wasJustedIssued: Bool { + return self.expires.addingTimeInterval(10) > Date() + } +} + +extension OSCKit { + public var session: Promise { + if let currentSession = Session.currentSession { + if currentSession.wasJustedIssued { + return Promise(value: currentSession) + } + if currentSession.isExpired { + return startSession + } + return update(session: currentSession) + } + return startSession + } + + var startSession: Promise { + return async { + let response = try await(self.execute(command: .startSession)) + return try Session(json: response) + } + } + + func update(session: Session) -> Promise { + return async { + do { + let response = try await(self.execute(command: .updateSession(sessionId: session.id))) + return try Session(json: response) + } catch { + return try await(self.startSession) + } + } + } +} + diff --git a/Source/Status.swift b/Source/Status.swift new file mode 100644 index 0000000..58960e9 --- /dev/null +++ b/Source/Status.swift @@ -0,0 +1,28 @@ +// +// Status.swift +// ThreeSixtyCamera +// +// Created by Zhigang Fang on 4/18/17. +// Copyright © 2017 Tappollo Inc. All rights reserved. +// + +import Foundation +import AwaitKit +import PromiseKit +import SwiftyyJSON + +extension OSCKit { + func waitForStatus(id: String) -> Promise { + return async { + let json: JSON = [ + "id": id + ] + let response = try await(self.requestJSON(endPoint: .status, params: json)) + if response["state"].string == "inProgress" { + try await(after(interval: 2)) + return try await(self.waitForStatus(id: id)) + } + return response + } + } +} diff --git a/Source/Video.swift b/Source/Video.swift new file mode 100644 index 0000000..c2d4509 --- /dev/null +++ b/Source/Video.swift @@ -0,0 +1,63 @@ +// +// Video.swift +// ThreeSixtyCamera +// +// Created by Zhigang Fang on 4/18/17. +// Copyright © 2017 Tappollo Inc. All rights reserved. +// + +import Foundation +import SwiftyyJSON +import PromiseKit +import AwaitKit + +public enum VideoCaptureMode: String { + case interval + case composite + case bracket +} + +extension OSCKit { + + public func startCapture(mode: VideoCaptureMode = .interval) -> Promise { + return async { + let session = try await(self.session) + try await(self.execute(command: .setOptions(options: [CaptureMode.video], sessionId: session.id))) + return try await(self.execute(command: ._startCapture(sessionId: session.id, mode: mode))) + } + + } + + public func stopCapture() -> Promise { + return async { + let session = try await(self.session) + // Saving first item before capturing video + // This is due to the face THETA API v2.0 does not return a file URL when capture finishes + // https://developers.theta360.com/en/docs/v2.0/api_reference/commands/camera._stop_capture.html + let lastItem = try await(self.getLatestMediaItem(withPredicate: const(value: true))) + try await(self.execute(command: ._stopCapture(sessionId: session.id))) + // After stop capturing video, wait until it returns a new item with type being .video + let mediaItem = try await(self.getLatestMediaItem(withPredicate: { + $0.url != lastItem.url && $0.type ~= .video + })) + return mediaItem.url + } + } + + public func getVideo(url: String, type: DownloadType = .full) -> Promise { + return async { + let device = try await(self.deviceInfo) + let cacheKey = try (device.serial + url).addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) !! SDKError.unableToCreateVideoCacheKey + let cacheFolder = try NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first.map({ + URL(fileURLWithPath: $0) + }) !! SDKError.unableToFindCacheFolder + let fileURL = cacheFolder.appendingPathComponent(cacheKey) + if FileManager.default.fileExists(atPath: fileURL.path) { + return fileURL + } + let data = try await(self.requestData(command: ._getVideo(fileUri: url, _type: type))) + try data.write(to: fileURL) + return fileURL + } + } +}