#
引言智汀家庭云iOS版 是一款在 Swift 5 中开发的 iOS智能家居APP。该应用一直在积极升级以采用 iOS和 Swift 语言的最新功能。此应用可以发现和连接家庭网络内符合相对应协议的终端产品,并基于这些产品打造接地气的生活场景,提供人性化的信息提示和交互,以及便捷的配套服务。
轻松控制设备
您可以方便地调节智能灯的亮度和色温、智能控制开关插座、智能窗帘、空调的温度等等,即使不在家也能远程控制家里的智能设备
查看设备运行状况
您可以在APP上查看每个设备的运行状态,是否开启或关闭。
设置相应的控制场景
#
1. 快速上手#
1.1 开发工具- 当前版本适用于 Xcode 版本 Xcode 13 。如果您使用不同的 Xcode 版本,请查看之前的版本。
- 此版本为仅使用 Swift 5 支持 iOS 13+。
#
1.2 源码地址Git Hub
名称 URL 描述 sa-ios-sdk https://github.com/zhiting-tech/sa-ios-sdk iOS源码 gitee
名称 URL 描述 sa-ios-sdk https://gitee.com/zhiting-tech/sa-ios-sdk iOS源码
#
1.3 构建版本克隆存储库
bash $ git clone https://xxxx/sa-ios-sdk.git
CocoaPods
安装CocoaPods,详情可查询CocoaPods
CocoaPods安装完成后,请用CocoaPods下载第三方库
$ cd sa-ios-sdk$ pod install
如遇下载失败,则可能需科学上网,自行配置网络代理。
运行程序
pod install 成功后,在模拟器中编译并运行应用程序。
如果您没有看到任何数据,请检查 "Simulator" -> "Debug" -> "Location" 以更改位置。
#
2. 开发指南#
2.1 设计模式本项目采用MVC设计模式
#
2.2 组织架构- 项目组织架构如下图
1)APP 内是一些公共库文件
2)Classes 内是项目内各模块的Controller,内置均Model-View-Controller来设计与开发。
#
2.3 Caches:本地化存储篇智汀家庭云iOS版 项目的本地化存储我们采用的是Realm数据库进行存储。
Realm优势:
- 兼顾iOS和Android两个平台;
- 简单易用,学习成本低;
- 提供了一个轻量级的数据库查看工具,开发者可以查看数据库当中的内容,执行简单的插入和删除数据的操作。
Realm支持事务,满足ACID:
- 原子性(Atomicity)
- 一致性(Consistency)
- 隔离性(Isolation)
- 持久性(Durability)。
#
2.3.1 RealmSwift安装CocoaPods导入
pod 'RealmSwift'pod install
导入头文件
import RealmSwift
封装文件路径:/Classes/Caches/LocalCache.swift
#
2.3.2 数据库操作LocalCache.swift文件内总共有6份表格,分别是:
- AraCache
- LocationCache
- DeviceCache
- SceneCache
- SceneItemCache
- UserCache
我们以UserCache为例子介绍一下封装过程:
- 创建表格属性
class UserCache: Object { @objc dynamic var nickname = "" @objc dynamic var phone = "" @objc dynamic var icon_url = "" @objc dynamic var user_id = 0}
- 添加操作方法
//更新用户信息 static func update(from user: User) { let realm = try! Realm() if let userCache = realm.objects(UserCache.self).first {//查找用户表 try? realm.write { if user.nickname != "" { userCache.nickname = user.nickname } userCache.icon_url = user.icon_url userCache.phone = user.phone userCache.user_id = user.user_id } } else { let userCache = UserCache() if user.nickname != "" { userCache.nickname = user.nickname } userCache.user_id = user.user_id userCache.icon_url = user.icon_url userCache.phone = user.phone try? realm.write { realm.add(userCache) } } }
//获取所有用户信息 static func getUsers() -> [User] { let realm = try! Realm() var users = [User]() let userCaches = realm.objects(UserCache.self) userCaches.forEach { let user = User() user.nickname = $0.nickname user.icon_url = $0.icon_url user.phone = $0.phone user.user_id = $0.user_id users.append(user) } return users }
任何时候都需要获取Realm实例,每个线程只需要使用一次即可。
let realm = try! Realm()
关于更多的RealmSwift的初级操作(增删改查),可查阅《简书: 浅谈RealmSwift》,内容十分详细讲解在开发中对数据库的操作。
#
2.4 Network:网络层协议篇#
2.4.1 ApiService介绍文件路径:classes/Network/ApiService.swift
- ApiService 请求携带参数
/// 接口枚举enum ApiService { // login & register case register(country_code: String = "86", phone: String, password: String, captcha: String, captcha_id: String) case login(phone: String, password: String) case logout case captcha(type: CaptchaType, target: String, country_code: String = "86") case editUser(area: Area = AuthManager.shared.currentArea, user_id: Int, nickname: String = "", account_name: String, password: String) case bindCloud(area: Area, cloud_area_id: String, cloud_user_id: Int, url: String, sa_id: String? = nil) /// 云端账号信息 case cloudUserDetail(id: Int) /// 编辑云端账号信息 case editCloudUser(user_id: Int, nickname: String = "")
//sa case syncArea(syncModel: SyncSAModel, url: String, token: String) case checkSABindState(url: String)
// device case deviceList(type: Int = 0, area: Area) case addDiscoverDevice(device: DiscoverDeviceModel, area: Area) case addSADevice(url: String, device: DiscoverDeviceModel) case deviceDetail(area: Area, device_id: Int) case editDevice(area: Area, device_id: Int, name: String = "", location_id: Int = -1) case deleteDevice(area: Area, device_id: Int) case getDeviceAccessToken(area: Area)
// scene case sceneList(type: Int = 0, area: Area = AuthManager.shared.currentArea) case createScene(scene: SceneDetailModel, area: Area = AuthManager.shared.currentArea) case sceneDetail(id: Int, area: Area = AuthManager.shared.currentArea) case editScene(id: Int, scene: SceneDetailModel, area: Area = AuthManager.shared.currentArea) case deleteScene(id: Int, area: Area = AuthManager.shared.currentArea) case sceneExecute(scene_id: Int, is_execute: Bool, area: Area = AuthManager.shared.currentArea) case sceneLogs(start: Int = 0, size: Int = 20, area: Area = AuthManager.shared.currentArea)
// brand case brands(name: String, area: Area = AuthManager.shared.currentArea) case brandDetail(name: String, area: Area = AuthManager.shared.currentArea) // plugin case pluginDetail(plugin_id: String, area: Area = AuthManager.shared.currentArea) case downloadPlugin(area: Area = AuthManager.shared.currentArea, url: String, destination: DownloadRequest.Destination) // area case defaultLocationList case areaList case createArea(name: String, locations_name: [String]) case areaDetail(area: Area) case changeAreaName(area: Area, name: String) case deleteArea(area: Area, is_del_cloud_disk: Bool) case quitArea(area: Area) case getInviteQRCode(area: Area, role_ids: [Int]) case scanQRCode(qr_code: String, url: String, nickname: String, token: String?) // members case memberList(area: Area) case userDetail(area: Area, id: Int) case deleteMember(area: Area, id: Int) case editMember(area: Area, id: Int, role_ids: [Int]) // roles case rolesList(area: Area) case rolesPermissions(area: Area, user_id: Int) // location case areaLocationsList(area: Area) case locationDetail(area: Area, id: Int) case addLocation(area: Area, name: String) case changeLocationName(area: Area, id: Int, name: String) case deleteLocation(area: Area, id: Int) case setLocationOrders(area: Area, location_order: [Int]) // diskAuth case scopeList(area: Area) case scopeToken(area: Area, scopes: [String]) //转移拥有者 case transferOwner(area: Area,id: Int) //获取数据通道 case temporaryIP(area: Area, scheme: String = "http") //获取数据通道 case temporaryIPBySAID(sa_id: String, scheme: String = "http") //SC获取SAtoken case getSAToken(area: Area) //发现设备 - 设备列表 case commonDeviceList(area: Area) //插件包 —— 检测更新 case checkPluginUpdate(id: String, area: Area) //获取验证码 case getCaptcha(area: Area) //设置找回凭证权限 case settingTokenAuth(area: Area, tokenModel: TokenAuthSettingModel)}
var cloudUrl = "云端IP地址"
- ApiService 请求IP地址(BaseUrl)
extension ApiService { var baseURL: URL { switch self { case .logout, .login, .register, .captcha, .cloudUserDetail, .editCloudUser, .defaultLocationList, .areaList, .createArea: return URL(string: "\(cloudUrl)/api")! case .checkPluginUpdate(_, let area): return area.requestURL case .commonDeviceList(let area): return area.requestURL case .scanQRCode(_, let url, _, _): return URL(string: "\(url)/api")! case .addSADevice(let url, _): return URL(string: "\(url)/api")! case .checkSABindState(let url): return URL(string: "\(url)/api")!
...}
- ApiService 请求地址的拼接(Path)
var path: String {
switch self {
case .sceneList: return "/scenes" case .createScene: return "/scenes" case .deleteScene(let scene_id ,_): return "/scenes/\(scene_id)" case .editScene(let scene_id, _, _): return "/scenes/\(scene_id)" case .sceneExecute(let scene_id,_, _): return "/scenes/\(scene_id)/execute" case .sceneDetail(let scene_id ,_): return "/scenes/\(scene_id)" case .sceneLogs: return "/scene_logs" ... } }
- ApiService 请求方式
var method: Moya.Method { switch self {
case .sceneList: return .get case .createScene: return .post case .deleteScene: return .delete case .sceneExecute: return .post case .sceneDetail: return .get case .editScene: return .put case .sceneLogs: return .get ... }}
- ApiService 创建Parameters Task
var task: Task { switch self {
case .sceneLogs(let start, let size, _): return .requestParameters(parameters: ["start": start, "size": size], encoding: URLEncoding.default)
case .sceneDetail(_, _): return .requestPlain
case .createScene(let scene, _): let json = scene.toJSON() ?? [:] return .requestParameters(parameters: json, encoding: JSONEncoding.default)
case .deleteScene(_, _): return .requestPlain
case .editScene(_, let scene, _): let json = scene.toJSON() ?? [:] return .requestParameters(parameters: json, encoding: JSONEncoding.default)
case .sceneExecute(_,let is_execute, _): return .requestParameters(parameters: ["is_execute": is_execute], encoding:JSONEncoding.default) ...
}}
#
2.4.2 扩展Moya数据请求方式:extension MoyaProvider {
@discardableResult func requestModel<T: BaseModel>(_ target: Target, modelType: T.Type, successCallback: ((_ response: T) -> Void)?, failureCallback: ((_ code: Int, _ errorMessage: String) -> Void)? = nil) -> Moya.Cancellable? { return request(target) { (result) in switch result { case .success(let response): if printDebugInfo { print("-----------------------------< ApiService >--------------------------------") print(Date()) print("---------------------------------------------------------------------------") print("header: \(target.headers ?? [:])") print("---------------------------------------------------------------------------") print("method: \(target.method.rawValue)") print("---------------------------------------------------------------------------") print("baseUrl: \(target.baseURL)") print("---------------------------------------------------------------------------") print("target: \(target.path)") print("---------------------------------------------------------------------------") print("parameters: \(target.task)") } guard response.statusCode == 200, let model = response.data.map(ApiServiceResponseModel<T>.self) else { failureCallback?(response.statusCode, "error: \(String(data: response.data, encoding: .utf8) ?? "unknown") ") print("---------------------------------------------------------------------------") print("error: \(String(data: response.data, encoding: .utf8) ?? "unknown") errorCode:\(response.statusCode)") print("---------------------------------------------------------------------------\n\n") return } if printDebugInfo { print("---------------------------------------------------------------------------") print(String(data: response.data, encoding: .utf8) ?? "") print("---------------------------------------------------------------------------\n\n") }
if model.status == 0 { successCallback?(model.data) } else { failureCallback?(model.status, model.reason) if model.status == 2008 || model.status == 2009 { /// 云端登录状态丢失 DispatchQueue.main.async { SceneDelegate.shared.window?.makeToast("登录状态丢失".localizedString) AuthManager.shared.lostLoginState() } }
} case .failure(let error): let moyaError = error as MoyaError let statusCode = moyaError.response?.statusCode ?? -1 let errorMessage = "error" if printDebugInfo { print("-----------------------------< ApiService >--------------------------------") print(Date()) print("---------------------------------------------------------------------------") print("header: \(target.headers ?? [:])") print("---------------------------------------------------------------------------") print("method: \(target.method.rawValue)") print("---------------------------------------------------------------------------") print("baseUrl: \(target.baseURL)") print("---------------------------------------------------------------------------") print("target: \(target.path)") print("---------------------------------------------------------------------------") print("parameters: \(target.task)") print("---------------------------------------------------------------------------") print("Error: \(error.localizedDescription) ErrorCode: \(statusCode)") print("---------------------------------------------------------------------------\n\n") } failureCallback?(statusCode, errorMessage) return }
} } }
2.4.2 ApiServiceManager介绍
ApiServiceManager是基于Apiservice的再一层封装,方便对临时通道的处理和外部调用网络请求。详情见ApiServiceManager.swift
class ApiServiceManager: NSObject { static let shared = ApiServiceManager() override private init() { super.init() } /// 处理证书信任的urlSession lazy var mySession: Moya.Session = { let configuration = URLSessionConfiguration.default configuration.headers = .default return Session(configuration: configuration, delegate: MySessionDelegate(), startRequestsImmediately: false) }() lazy var apiService = MoyaProvider<ApiService>(requestClosure: requestClosure,session: mySession)
}extension ApiServiceManager { /// 获取验证码 /// - Parameters: /// - type: 验证码类型 /// - target: 目标 /// - successCallback: 成功回调 /// - failureCallback: 失败回调 /// - Returns: nil func getCaptcha(type: CaptchaType, target: String, successCallback: ((CaptchaResponse) -> ())?, failureCallback: ((Int, String) -> ())?) { apiService.requestModel(.captcha(type: type, target: target), modelType: CaptchaResponse.self, successCallback: successCallback, failureCallback: failureCallback) } /// 注册 /// - Parameters: /// - phone: 手机号 /// - password: 密码 /// - captcha: 验证码 /// - captchaId: 验证码id /// - successCallback: 成功回调 /// - failureCallback: 失败回调 /// - Returns: nil func register(phone: String, password: String, captcha: String, captchaId: String, successCallback: ((RegisterResponse) -> ())?, failureCallback: ((Int, String) -> ())?) { apiService.requestModel(.register(phone: phone, password: password, captcha: captcha, captcha_id: captchaId), modelType: RegisterResponse.self, successCallback: successCallback, failureCallback: failureCallback) }
...}
- 外部请求数据接口方法 eg:请求场景列表数据
ApiServiceManager.shared.sceneList(type: 0) {[weak self] (respond) in guard let self = self else { return } DispatchQueue.main.async {
let list = SceneListModel() list.auto_run = respond.auto_run list.manual = respond.manual self.currentSceneList = list if respond.manual.count != 0 { //存储手动数据 SceneCache.cacheScenes(scenes: respond.manual, area_id: self.currentArea.id, sa_token: self.currentArea.sa_user_token, is_auto: 0) } if respond.auto_run.count != 0 { //存储自动数据 SceneCache.cacheScenes(scenes: respond.auto_run, area_id: self.currentArea.id, sa_token: self.currentArea.sa_user_token, is_auto: 1) } self.tableView.mj_header?.endRefreshing() self.checkAuthState() self.tableView.reloadData() semaphore.signal() } } failureCallback: {[weak self] (code, err) in guard let self = self else { return } DispatchQueue.main.async { self.tableView.mj_header?.endRefreshing() self.checkAuthState() self.tableView.reloadData() semaphore.signal() } }
#
2.4.3 临时通道功能如果当前网络环境不是处在对应家庭的sa环境下,且有登录的情况下,相关网络请求则会走临时通道。
临时通道的获取:
extension ApiServiceManager{ //获取临时通道地址 func requestTemporaryIP(area:Area, complete:( (String)->())?, failureCallback: ((Int, String) -> ())?) { //在局域网内则直接连接局域网 if area.bssid == NetworkStateManager.shared.getWifiBSSID() && area.bssid != nil || !AuthManager.shared.isLogin { complete?("") return } //获取本地存储的临时通道地址 let key = area.sa_user_token let temporaryJsonStr:String = UserDefaults.standard.value(forKey: key) as? String ?? "" let temporary = TemporaryResponse.deserialize(from: temporaryJsonStr) //验证是否过期,直接返回IP地址 if let temporary = temporary {//有存储信息 if timeInterval(fromTime: temporary.saveTime , second: temporary.expires_time) { //地址并未过期 complete?(temporary.host) return } } //过期,请求服务器获取临时通道地址 apiService.requestModel(.temporaryIP(area: area,scheme: "https"), modelType: TemporaryResponse.self) { response in //获取临时通道地址及有效时间,存储在本地 //更新时间和密码 let temporaryModel = TemporaryResponse() temporaryModel.host = response.host temporaryModel.expires_time = response.expires_time //当前时间 let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" temporaryModel.saveTime = dateFormatter.string(from: Date()) UserDefaults.standard.setValue(temporaryModel.toJSONString(prettyPrint:true), forKey: key) //返回ip地址 complete?(response.host) } failureCallback: { code, error in failureCallback?(code,error) } } // 时间间隔 private func timeInterval(fromTime: String , second: Int) -> Bool{ let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" //当前时间 let time = dateFormatter.string(from: Date()) //计算时间差 let timeNumber = Int(dateFormatter.date(from: time)!.timeIntervalSince1970-dateFormatter.date(from: fromTime)!.timeIntervalSince1970) // let timeInterval:CGFloat = CGFloat(timeNumber)/3600.0 return second > timeNumber } }
请求时示例:
/// 场景详情 /// - Parameters: /// - id: 场景ID /// - area_id:家庭ID /// - successCallback: 成功回调 /// - failureCallback: 失败回调 /// - Returns: nil func sceneDetail(id: Int, area: Area = AuthManager.shared.currentArea, successCallback: ((SceneDetailModel) -> ())?, failureCallback: ((Int, String) -> ())?) { requestTemporaryIP(area: area) { [weak self] ip in guard let self = self else { return } //获取临时通道地址 if ip != "" { area.temporaryIP = "https://" + ip + "/api" } //请求结果 self.apiService.requestModel(.sceneDetail(id: id, area: area), modelType: SceneDetailModel.self, successCallback: successCallback,failureCallback: failureCallback) } failureCallback: { code, err in failureCallback?(code,err) } }
#
2.4.4 判断请求地址的方法Area.swift
/// 是否在SA环境下 /// 请求的地址url(判断请求sa还是临时通道) var requestURL: URL { if bssid == NetworkStateManager.shared.getWifiBSSID() && bssid != nil {//局域网 return URL(string: "\(sa_lan_address ?? "")/api")! } else if AuthManager.shared.isLogin && id != nil { // 临时通道 return URL(string: temporaryIP)! } else { if let url = URL(string: "\(sa_lan_address ?? "http://")") { return url } return URL(string: "http://")! } }
#
2.5 智能设备置网篇iOS智能设备的置网协议,支持blufi蓝牙和softAP两种.在iOS端配网时会先检查本地是否存在对应的插件包然后进行更新或者下载,配网页面是将插件包中的html静态资源加载到wkwebview中实现的。配网中用到的方法则由app端原生实现后,提供接口给h5调用.
#
2.5.1 插件包更新及下载在进入配网页之前会先检查本地插件包是否存在和插件包版本,根据情况下载或者更新插件包.
//检测插件包是否需要更新 self.showLoadingView() ApiServiceManager.shared.checkPluginUpdate(id: device.plugin_id) { [weak self] response in guard let self = self else { return } let filepath = ZTZipTool.getDocumentPath() + "/" + device.plugin_id let cachePluginInfo = Plugin.deserialize(from: UserDefaults.standard.value(forKey: device.plugin_id) as? String ?? "") //检测本地是否有文件,以及是否为最新版本 if ZTZipTool.fileExists(path: filepath) && cachePluginInfo?.version == response.plugin.version { self.hideLoadingView() //直接打开插件包获取信息 let urlPath = "file://" + ZTZipTool.getDocumentPath() + "/" + device.plugin_id + "/" + device.provisioning let vc = WKWebViewController(link: urlPath) vc.device = device self.navigationController?.pushViewController(vc, animated: true) } else { //根据路径下载最新插件包,存储在document ZTZipTool.downloadZipToDocument(urlString: response.plugin.download_url ?? "", fileName: device.plugin_id) { [weak self] success in guard let self = self else { return } self.hideLoadingView() if success { //根据相对路径打开本地静态文件 let urlPath = "file://" + ZTZipTool.getDocumentPath() + "/" + device.plugin_id + "/" + device.provisioning let vc = WKWebViewController(link: urlPath) vc.device = device self.navigationController?.pushViewController(vc, animated: true) //存储插件信息 UserDefaults.standard.setValue(response.plugin.toJSONString(prettyPrint:true), forKey: device.plugin_id) } else { self.showToast(string: "下载插件包失败".localizedString) } } }
} failureCallback: { [weak self] code, err in self?.hideLoadingView() }
#
2.5.2 Blufi配网针对Blufi配网方式封装了一个工具类BluFiTool
import Foundationimport ESPProvisionimport Combine
class BluFiTool: NSObject { /// 用于局域网扫描设备 var udpDeviceTool: UDPDeviceTool?
var cancellables = Set<AnyCancellable>() /// 连接的设备 var device: ESPPeripheral?
/// blufi client var blufiClient: BlufiClient? /// 用于扫描Blufi设备 let bluFiHelper = ESPFBYBLEHelper.share() /// 已发现的设备 var discoveredDevices = [ESPPeripheral]() /// 扫描Blufi时过滤关键词 var filterContent = "ZT" /// 连接设备回调 var connectCallback: ((Bool) -> Void)? /// 配网结果回调 var provisionCallback: ((Bool) -> Void)? /// 添加设备回调 var addDeviceCallback: ((Bool) -> Void)? /// 置网成功flag var provisionFlag = false /// 硬件设备ID var deviceID = ""
/// 连接设备Blufi设备 func connect(device: ESPPeripheral) { self.device = device blufiClient?.close() blufiClient = BlufiClient() blufiClient?.centralManagerDelete = self blufiClient?.peripheralDelegate = self blufiClient?.blufiDelegate = self blufiClient?.connect(device.uuid.uuidString) } /// 发送配网信息 /// - Parameters: /// - ssid: wifi名称 /// - pwd: wifi密码 func configWifi(ssid: String, pwd: String) { let params = BlufiConfigureParams() params.opMode = OpModeSta params.staSsid = ssid params.staPassword = pwd blufiClient?.configure(params) }
/// 扫描blufi蓝牙设备 /// - Parameters: /// - filterContent: 过滤关键词 /// - callback: 发现设备回调 func scanBlufiDevices(filterContent: String = "BLUFI", callback: ((ESPPeripheral) -> Void)?) { self.filterContent = filterContent bluFiHelper.startScan { [weak self] blufiDevice in guard let self = self else { return } if self.shouldAddToSource(device: blufiDevice) { self.discoveredDevices.append(blufiDevice) callback?(blufiDevice) } } } /// 扫描并连接设备 /// - Parameter filterContent: 过滤关键词 func scanAndConnectDevice(filterContent: String = "BLUFI") { self.filterContent = filterContent bluFiHelper.startScan { [weak self] blufiDevice in guard let self = self else { return } if self.shouldAddToSource(device: blufiDevice) { self.stopScanDevices() self.device = blufiDevice self.connect(device: blufiDevice) } } DispatchQueue.main.asyncAfter(deadline: .now() + 10) { if self.device == nil { self.stopScanDevices() self.connectCallback?(false) } }
}
/// 停止扫描blufi蓝牙设备 func stopScanDevices() { bluFiHelper.stopScan() }
/// 判断是否应该将发现的设备添加至发现列表 /// - Parameter device: 待添加设备 /// - Returns: 是否添加 func shouldAddToSource(device: ESPPeripheral) -> Bool { if filterContent.count > 0 { if device.name.isEmpty || !device.name.hasPrefix(filterContent) { return false } } /// 已存在 if discoveredDevices.contains(where: { $0.uuid == device.uuid }) { return false } return true }
}
extension BluFiTool { /// 获取设备accessToken func getDeviceAccessToken(deviceID: String) { guard AuthManager.shared.currentArea.id != nil && !AuthManager.shared.currentArea.is_bind_sa else { print("家庭id不存在") return } ApiServiceManager.shared.getDeviceAccessToken(area: AuthManager.shared.currentArea) { [weak self] resp in guard let self = self else { return } self.connectDeviceToServer(deviceID: deviceID, accessToken: resp.access_token) } failureCallback: { code, err in print(err) }
}
/// 将设备连接服务器 /// - Parameter deviceID: 设备id func connectDeviceToServer(deviceID: String, accessToken: String) { udpDeviceTool = UDPDeviceTool() guard let areaId = AuthManager.shared.currentArea.id, AuthManager.shared.currentArea.is_bind_sa == false else { print("家庭id不存在或家庭已有SA,无需设置设备服务器") return }
/// 局域网内搜索到设备 udpDeviceTool?.deviceSearchedPubliser .receive(on: RunLoop.main) .sink(receiveValue: { [weak self] device in guard let self = self else { return } self.udpDeviceTool?.connectDeviceToSC(device: device, areaId: areaId, accessToken: accessToken) }) .store(in: &cancellables) /// 设备连接服务器 udpDeviceTool?.deviceSetServerPublisher .receive(on: RunLoop.main) .sink(receiveValue: { [weak self] success in guard let self = self else { return } }) .store(in: &cancellables) try? udpDeviceTool?.beginScan(notifyDeviceID: deviceID)
} }
extension BluFiTool: CBCentralManagerDelegate, CBPeripheralDelegate, BlufiDelegate { // MARK: - CBCentralManagerDelegate func centralManagerDidUpdateState(_ central: CBCentralManager) { } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { print("Blufi连接成功") connectCallback?(true) } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { print("Blufi连接失败") connectCallback?(false) } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { print("Blufi连接断开") connectCallback?(false) }
// MARK: - BlufiDelegate func blufi(_ client: BlufiClient, gattPrepared status: BlufiStatusCode, service: CBService?, writeChar: CBCharacteristic?, notifyChar: CBCharacteristic?) { print("Blufi准备就绪") } func blufi(_ client: BlufiClient, didNegotiateSecurity status: BlufiStatusCode) { print("Blufi安全校验成功") } func blufi(_ client: BlufiClient, didPostConfigureParams status: BlufiStatusCode) { print("Blufi已将发送配网信息至设备") } func blufi(_ client: BlufiClient, didReceiveDeviceStatusResponse response: BlufiStatusResponse?, status: BlufiStatusCode) { print("Blufi接受到设备响应") if status == StatusSuccess { if let isConnect = response?.isStaConnectWiFi(), isConnect == true { print("Blufi置网成功") provisionCallback?(true) } else { print("Blufi置网失败") provisionCallback?(false) } } else { print("Blufi置网失败") provisionCallback?(false) } } func blufi(_ client: BlufiClient, didReceiveCustomData data: Data, status: BlufiStatusCode) { print("Blufi接受到设备自定义响应") /// 设备ID guard let deviceID = String(data: data, encoding: .utf8) else { return } provisionFlag = true self.deviceID = deviceID.lowercased() print("设备id: \(self.deviceID )") DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.getDeviceAccessToken(deviceID: self.deviceID ) } }
func blufi(_ client: BlufiClient, didPostCustomData data: Data, status: BlufiStatusCode) { print("Blufi已将自定义信息发送至设备") } func blufi(_ client: BlufiClient, didReceiveError errCode: Int) { print("Blufi接受到错误") } }
#
2.5.3 SoftAP配网针对SoftAP配网方式封装了一个工具类SoftAPTool
import Foundationimport ESPProvisionimport Combineimport NetworkExtension
class SoftAPTool { /// 用于局域网扫描设备 var udpDeviceTool: UDPDeviceTool?
var cancellables = Set<AnyCancellable>() /// 置网成功flag var provisionFlag = false /// 置网之前的bssid var beforeBSSID: String?
/// 硬件设备ID var deviceID = ""
/// ESP设备 var device: ESPDevice? /// 设备拥有权 默认abcd1234 var devicePop = "abcd1234" init() { NetworkStateManager.shared.networkStatusPublisher .receive(on: RunLoop.main) .sink { [weak self] state in guard let self = self else { return } if state == .reachable && self.provisionFlag && self.deviceID != "" { /// 置网成功后回到局域网 将设备连接至服务器 DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.getDeviceAccessToken(deviceID: self.deviceID) } } } .store(in: &cancellables) }
/// 创建ESP设备 /// - Parameters: /// - deviceName: 设备名称(热点名称) e.g. "PROV_E2CF5C" /// - proofOfPossession: 设备pop(设备拥有权) e.g. "abcd1234" func createESPDevice(deviceName: String, proofOfPossession: String = "abcd1234", completeHandler: ((ESPDevice?) -> Void)? = nil) { devicePop = proofOfPossession ESPProvisionManager.shared.createESPDevice(deviceName: deviceName, transport: .softap, proofOfPossession: proofOfPossession) { [weak self] device, err in guard let self = self else { return } completeHandler?(device) self.device = device } } /// 连接设备 /// - Parameters: /// - device: 需要连接的esp设备 /// - connectHandler: 连接回调 func connectESPDevice(connectHandler: ((ESPSessionStatus) -> Void)?) { guard let device = device else { connectHandler?(.disconnected) return } device.connect(delegate: self, completionHandler: { [weak self] status in guard let self = self else { return } switch status { case .connected: let deviceID = NetworkStateManager.shared.getWifiBSSID()? .components(separatedBy: ":") .compactMap { $0 } .map { element -> String in if element.count == 1 { return "0\(element)" } else { return element } } .joined() self.deviceID = deviceID ?? "" default: break } connectHandler?(status) }) }
/// 置网ESP设备 /// - Parameters: /// - ssid: 置网ssid /// - passphrase: 密码 func provisionDevice(ssid: String, passphrase: String = "", completeHandler: ((ESPProvisionStatus) -> Void)?) { guard let device = device else { completeHandler?(.failure(.unknownError)) return } device.provision(ssid: ssid, passPhrase: passphrase) { [weak self] status in switch status { case .success: self?.provisionFlag = true default: break } completeHandler?(status) } } /// 通过ESP设备扫描发现可置网的设备 /// - Parameter device: ESP设备 func scanWifiList() { device?.scanWifiList(completionHandler: { wifiList, err in if let err = err { print("\(err.localizedDescription)") return } print("发现可置网的wifi列表")
}) }
}
extension SoftAPTool { /// 获取设备accessToken func getDeviceAccessToken(deviceID: String) { guard AuthManager.shared.currentArea.id != nil && !AuthManager.shared.currentArea.is_bind_sa else { print("家庭id不存在") return } ApiServiceManager.shared.getDeviceAccessToken(area: AuthManager.shared.currentArea) { [weak self] resp in guard let self = self else { return } self.connectDeviceToServer(deviceID: deviceID, accessToken: resp.access_token) } failureCallback: { code, err in print(err) }
}
/// 将设备连接服务器 /// - Parameter deviceID: 设备id func connectDeviceToServer(deviceID: String, accessToken: String) { udpDeviceTool = UDPDeviceTool() guard let areaId = AuthManager.shared.currentArea.id else { print("家庭id不存在") return }
/// 局域网内搜索到设备 udpDeviceTool?.deviceSearchedPubliser .receive(on: RunLoop.main) .sink(receiveValue: { [weak self] device in guard let self = self else { return } self.udpDeviceTool?.connectDeviceToSC(device: device, areaId: areaId, accessToken: accessToken) }) .store(in: &cancellables) /// 设备连接服务器 udpDeviceTool?.deviceSetServerPublisher .receive(on: RunLoop.main) .sink(receiveValue: { [weak self] success in guard let self = self else { return } }) .store(in: &cancellables) try? udpDeviceTool?.beginScan(notifyDeviceID: deviceID)
} }
extension SoftAPTool: ESPDeviceConnectionDelegate { func getProofOfPossesion(forDevice: ESPDevice, completionHandler: @escaping (String) -> Void) { completionHandler(devicePop) }}
extension SoftAPTool { /// 移除热点信息 /// - Parameter ssid: 需要移除的热点的ssid func removeConfiguration(ssid: String) { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) } /// 连接指定热点 /// - Parameters: /// - ssid: 热点ssid /// - pwd: 热点密码 /// - callback: 连接结果回调 func applyConfiguration(ssid: String, pwd: String, callback: ((_ success: Bool) -> Void)? = nil) { beforeBSSID = NetworkStateManager.shared.getWifiBSSID() let config = NEHotspotConfiguration(ssid: ssid, passphrase: pwd, isWEP: false) config.joinOnce = true NEHotspotConfigurationManager.shared.apply(config) { (error) in if NetworkStateManager.shared.getWifiSSID() == ssid { callback?(true) } else { callback?(false) }
} } /// 连接指定无密码热点 /// - Parameters: /// - ssid: 热点ssid /// - callback: 连接结果回调 func applyConfiguration(ssid: String, pwd: String? = nil, callback: ((_ success: Bool) -> Void)? = nil) { beforeBSSID = NetworkStateManager.shared.getWifiBSSID() NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid)
let config: NEHotspotConfiguration if let pwd = pwd { config = NEHotspotConfiguration(ssid: ssid, passphrase: pwd, isWEP: false) } else { config = NEHotspotConfiguration(ssid: ssid) } config.joinOnce = true NEHotspotConfigurationManager.shared.apply(config) { (error) in if NetworkStateManager.shared.getWifiSSID() == ssid && NetworkStateManager.shared.getWifiSSID() != nil { callback?(true) } else { callback?(false) }
} }}
#
2.5.4 提供给前端调用的方法根据前端提供的文档,由原生实现对应的方法给前端调用.具体实现在WkWebViewController.swift中.示例:
...
/// 通过蓝牙连接设备 func connectDeviceByBluetooth(params: Dictionary<String,Any>?, callBack:((_ response: Any?) -> ())?) { softAPTool.udpDeviceTool = nil guard let params = params, let filterContent = params["bluetoothName"] as? String else { return } var json = "" bluFiTool.connectCallback = { success in if success { json = "{\"status\": 0,\"error\": \"\"}" print("蓝牙设备连接成功") } else { json = "{\"status\": 1,\"error\": \"蓝牙设备连接失败\"}" print("蓝牙设备连接失败") } DispatchQueue.main.async { callBack?(json) } } bluFiTool.scanAndConnectDevice(filterContent: filterContent) }
/// 通过蓝牙给设备发送配网信息 func connectNetworkByBluetooth(params: Dictionary<String,Any>?, callBack:((_ response: Any?) -> ())?) { guard let params = params, let ssid = params["wifiName"] as? String, let pwd = params["wifiPass"] as? String else { return } var json = "" bluFiTool.provisionCallback = { success in if success { json = "{\"status\": 0,\"error\": \"\"}" } else { json = "{\"status\": 1,\"error\": \"设备配网失败\"}" } DispatchQueue.main.async { callBack?(json) } }
bluFiTool.configWifi(ssid: ssid, pwd: pwd)
}
...
#
2.6 业务功能:智汀家庭云iOS端,支持对智慧中心(SA)、智能设备的发现及控制.
#
2.6.1 扫描发现智慧中心(SA)智慧中心的发现主要流程为:
1.在局域网中广播hello包 2.对响应设备进行点对点通信获取加密后的token 3.利用解密后的token加密用于对设备通讯的消息和解密设备的响应.
具体协议请参照硬件协议文档,iOS端基于CocoaAsyncSocket封装了一个工具类UDPDeviceTool.swift,实现如下:
import UIKitimport CocoaAsyncSocketimport Combine
class UDPDeviceTool: NSObject { /// 发送的包类型 enum OperationType { /// 获取设备信息 case getDeviceInfo /// 设置设备服务器 case setServer(_ server: String, _ port: Int) }
/// sa的发布者 var saPubliser = PassthroughSubject<DiscoverSAModel, Never>()
/// 默认端口 private var portNumber: UInt16 = 54321 /// GCDAsyncUdpSocket private var udpSocket: GCDAsyncUdpSocket? /// 发现的设备 [设备id: 设备] private var devices = [String: UDPDevice]() /// 正在搜索的设备ID var searchingDeviceID: String? /// 搜索到指定id设备的发布者 var deviceSearchedPubliser = PassthroughSubject<UDPDevice, Never>() /// 设备设置服务器结果发布者 var deviceSetServerPublisher = PassthroughSubject<Bool, Never>()
/// 消息id lazy var id = 0 /// id: OperationType lazy var operationDict = [Int: OperationType]() override init() { super.init() setupUDPSocket(port: portNumber) }
deinit { udpSocket?.close() print("ScanSATool deinit.") } /// 建立UDPSocket /// - Parameter port: 绑定的端口号 private func setupUDPSocket(port: UInt16) { udpSocket = GCDAsyncUdpSocket(delegate: self, delegateQueue: .main) do { try udpSocket?.enableReusePort(true) try udpSocket?.enableBroadcast(true) try udpSocket?.bind(toPort: port) portNumber = port } catch { print("error happens when setting up UDPSocket") print("\(error.localizedDescription)") }
} /// 开始扫描发现 /// - Parameter notifyDeviceID: 扫描到指定ID的设备会通知订阅者 func beginScan(notifyDeviceID: String? = nil) throws { searchingDeviceID = notifyDeviceID print("开始UDP扫描") try udpSocket?.beginReceiving() cleanDevices() sendHello() } /// 停止扫描发现 func stopScan() { print("停止UDP扫描") udpSocket?.pauseReceiving() cleanDevices() } /// 发送hello包 private func sendHello() { let helloDatagram: [UInt8] = [0x21, 0x31, 0x00, 0x20, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF] let data = Data(helloDatagram) udpSocket?.send(data, toHost: "255.255.255.255", port: portNumber, withTimeout: -1, tag: 0) } /// 发送点对点包请求设备token /// - Parameter device: 设备 private func requestDeviceToken(device: UDPDevice) { /// header - 包头 let headBytes: [UInt8] = [0x21, 0x31] /// header - key部分 let keyData: Data if let key = device.key { /// 如果设备已有key guard let data = key.data(using: .utf8) else { return } keyData = data } else { /// 如果设备没有key,随机生成16位key let key = randomString(length: 16) guard let data = key.data(using: .utf8) else { return } keyData = data device.key = key } /// header - 序列号部分 let serialData: [UInt8] = [0x00, 0x00, 0x00, 0x00] /// header - 预留部分 let reserveBytes: [UInt8] = [0xff, 0xfe] /// header - 设备ID let deviceidBytes = device.id.hexaBytes
/// header - 包长度 let lengthBytes = withUnsafeBytes(of: Int16(32).bigEndian) { Data($0) } /// 包头 包长 预留 设备ID 序列号 MD5校验(key) body let data1 = Data(headBytes + lengthBytes + reserveBytes) let data2 = Data(deviceidBytes + serialData) let headerData = data1 + data2 + keyData
udpSocket?.send(headerData, toHost: device.host, port: device.port, withTimeout: -1, tag: 0) } /// 获取设备信息 /// - Parameter device: 设备 private func getDeviceInfo(device: UDPDevice) { /// 要有设备token才能进行操作 guard let token = device.token else { return }
/// header - 包头 let headBytes: [UInt8] = [0x21, 0x31] /// header - key部分 let keyBytes: [UInt8] if let key = device.key { /// 如果设备已有key guard let keyData = key.data(using: .utf8) else { return } keyBytes = Array(keyData) } else { /// 如果设备没有key,随机生成16位key return } /// header - 序列号部分 let serialData: [UInt8] = [0x00, 0x00, 0x00, 0x00] /// header - 预留部分 let reserveBytes: [UInt8] = [0xff, 0xff] /// header - 设备ID let deviceidBytes = device.id.hexaBytes
/// body部分 let bodyJSON = """ {"method":"get_prop.info","params":[],"id":\(id)} """ operationDict[id] = .getDeviceInfo id += 1 /// 利用设备token加密数据 guard let bodyData = bodyJSON.data(using: .utf8), let encryptedBodyData = UDPAesUtil.encrypt(bodyData, by: token) else { return } /// header - 包长度 let lengthBytes = withUnsafeBytes(of: Int16(32 + encryptedBodyData.count).bigEndian) { Data($0) } /// 包头 包长 预留 设备ID 序列号 MD5校验(key) body let data1 = Data(headBytes + lengthBytes + reserveBytes) let data2 = Data(deviceidBytes + serialData + keyBytes) let headerData = data1 + data2 let data = headerData + encryptedBodyData udpSocket?.send(data, toHost: device.host, port: device.port, withTimeout: -1, tag: 0) } /// 将设备连接至SC /// - Parameter device: 设备 /// - Parameter areaId: sc家庭id /// - Parameter accessToken: 家庭入网信息返回的token func connectDeviceToSC(device: UDPDevice, areaId: String, accessToken: String, server: String = "sacloud.zhitingtech.com", port: Int = 54321) { /// 设备要有token才能继续操作 guard let token = device.token else { return } /// 设备要有key才能继续操作 guard let key = device.key, let keyData = key.data(using: .utf8) else { return }
/// header - 包头 let headBytes: [UInt8] = [0x21, 0x31] /// header - key部分 let keyBytes = Array(keyData) /// header - 序列号部分 let serialData: [UInt8] = [0x00, 0x00, 0x00, 0x00] /// header - 预留部分 let reserveBytes: [UInt8] = [0xff, 0xff] /// header - 设备ID let deviceidBytes = device.id.hexaBytes
/// body部分 let bodyJSON = """ {"method":"set_prop.server","params":{"server":"\(server)","port":\(port),"access_token":"\(accessToken)","area_id": \"\(areaId)\" },"id": \(id)} """ /// 利用设备token加密数据 guard let bodyData = bodyJSON.data(using: .utf8), let encryptedBodyData = UDPAesUtil.encrypt(bodyData, by: token) else { return } /// header - 包长度 let lengthBytes = withUnsafeBytes(of: Int16(32 + encryptedBodyData.count).bigEndian) { Data($0) } /// 包头 包长 预留 设备ID 序列号 MD5校验(key) body let data1 = Data(headBytes + lengthBytes + reserveBytes) let data2 = Data(deviceidBytes + serialData + keyBytes) let headerData = data1 + data2 let data = headerData + encryptedBodyData operationDict[id] = .setServer(server, port) id += 1 udpSocket?.send(data, toHost: device.host, port: device.port, withTimeout: -1, tag: 0) }
/// 生成指导长度随机字符串 /// - Parameter length: 长度 /// - Returns: 随机字符串 private func randomString(length: Int) -> String { let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" return String((0..<length).map{ _ in letters.randomElement()! }) } /// 清除所有已发现设备 private func cleanDevices() { id = 0 devices.removeAll() }
}
extension UDPDeviceTool: GCDAsyncUdpSocketDelegate {
/// 接收到数据包 /// - Parameters: /// - sock: GCDAsyncUdpSocket /// - data: 数据包 /// - address: 地址 /// - filterContext: 用于过滤的上下文 func udpSocket(_ sock: GCDAsyncUdpSocket, didReceive data: Data, fromAddress address: Data, withFilterContext filterContext: Any?) { var addrStr: NSString? = nil var port: UInt16 = 0
GCDAsyncUdpSocket.getHost(&addrStr, port: &port, fromAddress: address) guard let addStr = addrStr, let addr = addStr.components(separatedBy: ":").last, port == portNumber // 过滤无关端口响应的数据包 else { return } print("------- data from -------") print("\(addr):\(port)")
print("-------receive udp data-------") let receive = Array(data) .map { "0x\(String($0, radix: 16, uppercase: true))"} .joined(separator: ", ") .replacingOccurrences(of: "\"", with: "") print("\(receive)")
/// 字节数组 let dataArray = Array(data) /// 至少32个字节 guard dataArray.count >= 32 else { return }
/// 数据包解包 /// ------- /// 包头部分 /// 包头:(2个字节) /// 其内容固定为0x21 0x31 if dataArray[0] != 0x21 || dataArray[1] != 0x31 { return } /// 包长:(2个字节) /// 包长包含整个数据包内容,包括数据前导 let lengthData = Data(dataArray[2...3]) /// 字节转Int16 let int16Length = lengthData.withUnsafeBytes { ptr in ptr.load(as: Int16.self) } /// Int16大端字节流转Int let length = Int(int16Length.bigEndian) print("包长度: \(length)") guard length >= 32 else { print("数据包长度小于32") return } /// 预留:(2个字节) /// 此值一直为0,对于hello数据包则其数据填充为0Xffff。下发TOKEN加密钥,其值为0Xfffe /// 设备ID:(4个字节) /// 设备唯一序列号,用与硬件设备相关唯一信息做绑定。如MAC; 对于”Hello”广播数据包则内容填充0Xffffffffffff,点对点则填充相应设备ID let deviceIdData = Data(dataArray[6...11]) let deviceId = deviceIdData.toHexString() print("设备ID: \(deviceId)") /// 若是新设备则加入到发现设备列表中 if devices[deviceId] == nil { let device = UDPDevice(id: deviceId, host: addr, port: port) devices[deviceId] = device /// 获取设备token requestDeviceToken(device: device) return }
/// 序列号:(4个字节) /// 序列号,由发送一方生成(每包数据的序列不同),接收一方回复时,消息需与收到信息的序列号一致 /// MD5校验:(16个字节) /// 计算整个数据包,包括MD5 字段本身,必须用 0 初始化。TOKEN密钥则填充其上报密钥内容 /// ------- /// body部分 /// 有效数据 :(包长-32个字节) /// 此数据采用AES-128加密方式。可变大小的数据负载使用高级加密进行加密标准 (AES)。 128 位密钥和初始化向量均来自令牌如下: /// /// 密钥 = MD5(令牌) /// /// IV = MD5(密钥 + 令牌) /// /// 在加密之前使用 PKCS#7 填充。操作模式是密码块链(CBC)。 /// /// 此字段采用CJSON格式,如: /// /// { /// "id": XXX, /// "method": "prop.config_router", /// "params": { /// "ssid": "WiFi network", /// "passwd": "WiFi password", /// "uid": "YYY" /// } /// }
guard dataArray.count > 32 else { print("数据包body: nil") return } guard let device = devices[deviceId] else { return } guard let key = device.key else { requestDeviceToken(device: device) return }
let bodyLength = length - 32 /// 数据包body部分 let bodyData = Data(dataArray[32..<(32 + bodyLength)]) /// 如果设备token为空,默认收到的数据包为加密后的设备token if device.token == nil { /// 解密后的设备token guard let decryptedToken = UDPAesUtil.decryptToken(bodyData, key: key) else { return } device.token = decryptedToken /// 获取设备信息 getDeviceInfo(device: device) return }
/// 通过设备token解密body数据 guard let token = device.token, let decryptedBodyData = UDPAesUtil.decrypt(bodyData, by: token), let jsonStr = String(data: decryptedBodyData, encoding: .utf8), let dict = try? JSONSerialization.jsonObject(with: decryptedBodyData, options: .mutableContainers) as? [String: Any], let msgId = dict["id"] as? Int else { print(bodyData.toHexString()) print("failed to unwrap json string.") return }
print("数据包解密后body: ")
guard let type = operationDict[msgId] else { return } switch type { /// 获取设备信息 case .getDeviceInfo: guard let response = UDPDeviceResponse<UDPDeviceInfo>.deserialize(from: jsonStr) else { print("获取设备信息json解析错误") return } print(response.toJSONString() ?? "") device.info = response.result /// 如果发现的设备是SA,通过saPubliser发布给订阅者 if let info = device.info, info.model == "smart_assistant" { let sa = DiscoverSAModel() let name = "SA " + (info.sa_id ?? "") sa.name = name sa.model = info.model let saPort = info.port ?? "37965" /// SA请求地址 sa.address = "http://\(device.host):\(saPort)" sa.sw_version = info.sw_ver ?? "" saPubliser.send(sa) } /// 如果发现的设备是 搜索指定ID的设备, 通过deviceSearchedPubliser发布给订阅者 if deviceId == searchingDeviceID { if let searched = devices[deviceId] { deviceSearchedPubliser.send(searched) } } /// 设置设备服务器 case .setServer(let server, let port): guard let response = UDPDeviceResponse<UDPDeviceServerResult>.deserialize(from: jsonStr) else { print("设置设备服务器json解析错误") return } print(response.toJSONString() ?? "") guard let resultServer = response.result?.server, let resultPort = response.result?.port else { print("设置设备服务器失败") deviceSetServerPublisher.send(false) return } /// 判断设置的服务器和返回的服务器是否一致(是否设置成功)
if server == resultServer && port == resultPort { print("设置设备服务器成功") deviceSetServerPublisher.send(true) } else { print("设置设备服务器失败") deviceSetServerPublisher.send(false) } } } }
#
2.6.2 SA发现智能设备- SA扫描发现设备:DiscoverViewController.swift
///扫描设备,WebSocket if !area.sa_user_token.contains("unbind") { //已绑定SA的家庭通过websocket发现设备 websocket.executeOperation(operation: .discoverDevice(domain: "yeelight")) } else { //添加设备 ... }
- 添加智能设备:ConnectDeviceViewController.swift
///添加设备,服务器接口 apiService.requestModel(.addDiscoverDevice(device: device, area_id: authManager.currentArea.id), modelType: ResponseModel.self, successCallback: { [weak self] response in guard let self = self else { return } let success = response.device_id != -1 if success { self.removeCallback?() self.device_id = response.device_id self.plugin_url = response.plugin_url self.finishLoadingDevice() } else { self.failToConnect() }}
#
2.6.3 智能设备置网#
2.7 业务功能:场景篇#
2.7.1 添加场景- 创建场景:EditSceneViewController.swift
private func createScene() { guard let name = inputHeader.textField.text else { return } if name == "" { showToast(string: "场景名称不能为空".localizedString) return } if scene.scene_conditions.count == 0 { showToast(string: "请先添加条件".localizedString) return } if scene.scene_tasks.count == 0 { showToast(string: "请先添加执行任务".localizedString) return } scene.name = name /// 执行条件 if scene.scene_conditions.first?.condition_type == 0 { // 手动 scene.auto_run = false } else { // 自动 scene.auto_run = true if self.conditionHeader.conditionRelationshipType == .all { // 满足所有条件 scene.condition_logic = 1 } else { // 满足任一条件 scene.condition_logic = 2 } if scene.time_period == nil { scene.time_period = 1 let format = DateFormatter() format.dateStyle = .medium format.timeStyle = .medium format.dateFormat = "yyyy:MM:dd HH:mm:ss" if let startTime = format.date(from: "2000:01:01 00:00:00")?.timeIntervalSince1970 { scene.effect_start_time = Int(startTime) } if let endTime = format.date(from: "2000:01:02 00:00:00")?.timeIntervalSince1970 { scene.effect_end_time = Int(endTime) } scene.repeat_type = 1 scene.repeat_date = "1234567" } } /// 请求接口 saveButton.selectedChangeView(isLoading: true) apiService.requestModel(.createScene(scene: scene.transferedEditModel), modelType: BaseModel.self) { [weak self] response in self?.showToast(string: "创建成功".localizedString) self?.navigationController?.popViewController(animated: true) } failureCallback: { [weak self] (code, err) in self?.showToast(string: err) self?.saveButton.selectedChangeView(isLoading: false) } }
#
2.7.2 场景控制场景的控制,包括手动场景的执行、自动场景的开启/关闭。
- 执行、开启/关闭场景:SceneCell.swift
private func updateAction(_ isOn:Bool,_ isAuto: Bool) { self.apiService.requestModel(.sceneExecute(scene_id: self.currentSceneModel!.id, is_execute: isOn), modelType: isSuccessModel.self) {[weak self] respond in guard let self = self else { return } if respond.success { if isAuto { self.executiveBtn.selectedChangeView(isLoading: false) self.executiveCallback!("自动执行\(isOn ? "开启":"关闭")成功") }else{ self.executiveBtn.selectedChangeView(isLoading: false) self.executiveCallback!("手动执行成功") } } } failureCallback: { code, err in if isAuto { //恢复状态 self.switchIsOn = !self.switchIsOn self.autoSwitch.switchIsOn = !self.autoSwitch.switchIsOn //按钮样式恢复 self.executiveBtn.selectedChangeView(isLoading: false) self.executiveCallback!("自动执行\(isOn ? "开启":"关闭")失败") }else{ self.executiveBtn.selectedChangeView(isLoading: false) self.executiveCallback!("手动执行失败") } } }
- 闭包结果回调:SceneViewController.swift
在delegate方法cellForRowAt内cell的闭包结果回调
cell.executiveCallback = {[weak self] result in guard let self = self else { return } //提示执行结果 self.showToast(string: result) //重新刷新列表,更新执行后状态 self.requestNetwork() }
#
2.7.3 编辑场景枚举定义:SceneType
编辑场景:EditSceneViewController.swift
enum SceneType { case edit case create }
编辑场景与创建场景共处一个控制器,通过枚举值切换UI及执行操作
@objc private func onClickDone() { if self.type == .create { self.createScene() } else { self.editScene() } } private func editScene() { guard let id = scene_id else { return } guard let name = inputHeader.textField.text else { return } if name == "" { showToast(string: "场景名称不能为空".localizedString) return } if scene.scene_conditions.count == 0 { showToast(string: "请先添加条件".localizedString) return } if scene.scene_tasks.count == 0 { showToast(string: "请先添加执行任务".localizedString) return } scene.name = name saveButton.selectedChangeView(isLoading: true) apiService.requestModel(.editScene(id: id, scene: scene.transferedEditModel), modelType: BaseModel.self) { [weak self] _ in self?.showToast(string: "修改成功".localizedString) self?.navigationController?.popViewController(animated: true) } failureCallback: { [weak self] (code, err) in self?.showToast(string: err) self?.saveButton.selectedChangeView(isLoading: false) }
}
#
2.7.4 删除场景- 删除场景:EditSceneViewController.swift
private func deleteScene() { guard let id = scene_id else { return } tipsAlert?.isSureBtnLoading = true apiService.requestModel(.deleteScene(id: id), modelType: SceneDetailModel.self) { [weak self] response in self?.tipsAlert?.removeFromSuperview() self?.showToast(string: "删除成功".localizedString) self?.navigationController?.popViewController(animated: true)
} failureCallback: { [weak self] (code, err) in self?.tipsAlert?.isSureBtnLoading = false self?.showToast(string: err) } }
#
2.8 业务功能:Web端专业版集成智汀家庭云iOS版,使用WKWebView的WKScriptMessageHandler实现Web端专业版集成。
WKWebView是Apple在iOS8推出的WebKit框架中的负责网页的渲染与展示的类,相比UIWebView速度更快,占用内存更少,支持更多的HTML特性。WKScriptMessageHandler是WebKit提供的一种在WKWebView上进行JS消息控制的协议。
#
2.8.1 iOS调用JS:iOS调用JS方式是通过WKWebView的-evaluateJavaScript:completionHandler的方法来实现的。
文件:WKHandlerSwift.swift
/// 执行js脚本 /// - Parameters: /// - js: js /// - completed: completed /// - Returns: void public func evaluateJavaScript(js:String!, withCompleted completed:((_ data:Any?, _ error:Error?) ->Void)?) -> Void { self.webView.evaluateJavaScript(js, completionHandler: { (data:Any?, error:Error?) in completed?(data,error) }) }
/// 执行js脚本,同步返回 /// - Parameters: /// - js: js /// - error: error /// - Returns: void public func synEvaluateJavaScript(js:String!, withError error:inout UnsafeMutablePointer<Error>?) -> Any? { var result:Any? var success:Bool? = false var result_Error:Error? self.evaluateJavaScript(js: js, withCompleted: { (data:Any?, tmp_error:Error?) in if tmp_error != nil { result = data success = true } else { result_Error = tmp_error } }) while success != nil { RunLoop.current.run(mode: .default, before: .distantFuture) } if error != nil { do { try error = withUnsafeMutablePointer(to: &result_Error, result_Error as! (UnsafeMutablePointer<Error?>) throws -> UnsafeMutablePointer<Error>?) } catch { #if DEBUG print("WKEventHandlerNameSwift error:%@",error) #endif } } return result }
#
2.8.2 JS调用iOSWKEventHandlerProtocol代理方法:JS与iOS定义好方法名称,方便JS进行调用
JS代码:Web-扩展开发:jsbridge使用
iOS代码:
//! 第一步:导入WebKit框架头文件import WebKit //! 第二步:WKWebViewWKScriptMessageHandlerController遵守WKScriptMessageHandler协议extension DeviceWebViewController: WKEventHandlerProtocol{
} //! 第三步:使用添加了ScriptMessageHandler的userContentController配置configuration eventHandler = WKEventHandlerSwift(webView, self) let config = WKWebViewConfiguration() config.preferences = WKPreferences() config.preferences.minimumFontSize = 10 config.preferences.javaScriptEnabled = true config.preferences.javaScriptCanOpenWindowsAutomatically = true config.processPool = WKProcessPool() config.applicationNameForUserAgent = "zhitingua " + (config.applicationNameForUserAgent ?? "") //! 第四步:为userContentController添加ScriptMessageHandler,并指明name let usrScript:WKUserScript = WKUserScript.init(source: WKEventHandlerSwift.handleJS(), injectionTime: .atDocumentStart, forMainFrameOnly: true) config.userContentController = WKUserContentController() config.userContentController.addUserScript(usrScript) config.userContentController.add(self.eventHandler, name: WKEventHandlerNameSwift)
//! 第五步:使用configuration对象初始化webView webView = WKWebView(frame: .zero, configuration: config) webView.uiDelegate = self webView.navigationDelegate = self webView.allowsBackForwardNavigationGestures = true webView.scrollView.contentInsetAdjustmentBehavior = .never webView.addObserver(self, forKeyPath: "estimatedProgress", options: .new, context: nil)
//! 第六步:WKWebView收到ScriptMessage时回调此方法#pragma mark - WKScriptMessageHandler- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { if ([message.name caseInsensitiveCompare:@"jsToOc"] == NSOrderedSame) { [WKWebViewWKScriptMessageHandlerController showAlertWithTitle:message.name message:message.body cancelHandler:nil]; }}
extension DeviceWebViewController: WKEventHandlerProtocol { //MARK:WKEventHandlerProtocol func nativeHandle(funcName: inout String!, params: Dictionary<String, Any>?, callback: ((Any?) -> Void)?) { if funcName == "networkType" { networkType(callBack: callback) } else if funcName == "setTitle" { setTitle(params: params ?? [:]) } else if funcName == "getUserInfo" { getUserInfo(callBack: callback) } else if funcName == "isApp" { isApp(callBack: callback) } else if funcName == "isProfession" { isProfession(callBack: callback) } }
//下面为执行的方法 func setTitle(params:Dictionary<String,Any>) {
}
func networkType(callBack:((_ response:Any?) -> ())?) {
}
...}
实现原理:
1、JS与iOS约定好jsToOc方法,用作JS在调用iOS时的方法;
2、iOS使用WKUserContentController的addScriptMessageHandler:name方法监听name为jsToOc的消息;
3、JS通过window.webkit.messageHandlers.func.postMessage()的方式对js调用iOS方法发送消息;
4、iOS在userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) 方法中读取name为 “zhitingua” 的消息数据 message.body。
注意事项:
config.userContentController.add(self.eventHandler, name: “”)会引起循环引用问题。
一般来说,在合适的时机removeScriptMessageHandler可以解决此问题,如下:
/// 清空handler的数据信息, 注入的脚本。绑定事件信息等等 /// - Parameter handler: handler /// - Returns: void public func cleanHandler( handler:inout WKEventHandlerSwift!) -> Void { if (handler.webView != nil) { handler.webView.evaluateJavaScript("zhiting.removeAllCallBacks();", completionHandler: nil) handler.webView.configuration.userContentController.removeScriptMessageHandler(forName: WKEventHandlerNameSwift) } handler = nil }
#
2.9 业务扩展:活动情景篇#
2.9.1 活动情景分析- 终端存储
- 室内智慧中心(以下简称SA)请求
- 室外智汀云(以下简称SC)中转
本篇章的目的在于解决以下三大问题:
- 解决存储数据同步问题
- 明确在什么条件下请求哪个服务端
- 各种活动场景下的数据同步逻辑
#
2.9.2 如何解决存储数据同步问题?在解决这个问题之前,我们先回忆一下之前篇章介绍的,iOS终端的本地数据库采用的是realm数据库,表设计结构如下:
realm本地数据库
表名 | 描述 |
---|---|
AreaCache | 家庭/公司表 |
LocationCache | 房间/区域表 |
DeviceCache | 设备表 |
SceneCache | 场景表 |
SceneItemCache | 场景任务关联表 |
UserCache | 用户表 |
-附录: iOS数据库设计文档
上述表设计中,体现以下几大功能数据的存储:
- 家庭/公司
- 房间/区域
- 设备
- 场景
- 个人信息
数据同步如下图示:
在开始这个话题之前,我们先看看两段代码的设计:
家庭model
class Area: BaseModel { /// Area's id var id: String? /// Area's name var name = "" /// isbind smartAssistant var is_bind_sa: Bool = false /// smartAssistant's user_id var sa_user_id = 1 /// smartAssistant's token var sa_user_token = "" /// sa的wifi名称 var ssid: String? /// sa的地址 var sa_lan_address: String? /// sa的mac地址 var bssid: String? /// 是否已经设置sa专业版账号 var setAccount: Bool? /// sa专业版账号名 var accountName: String? /// 云端用户的user_id var cloud_user_id = -1 /// 是否需要重新将SA绑定云端 var needRebindCloud = false /// 是否允许找回凭证 var isAllowedGetToken = true
func toAreaCache() -> AreaCache { let cache = AreaCache() cache.id = id cache.name = name cache.sa_user_token = sa_user_token cache.sa_user_id = sa_user_id cache.is_bind_sa = is_bind_sa cache.ssid = ssid cache.sa_lan_address = sa_lan_address cache.bssid = bssid cache.cloud_user_id = cloud_user_id cache.needRebindCloud = needRebindCloud if let is_set_password = setAccount { cache.setAccount = is_set_password } return cache } /// 临时通道地址 var temporaryIP = "\(cloudUrl)/api" /// 请求的地址url(判断请求sa还是sc) var requestURL: URL { if bssid == NetworkStateManager.shared.getWifiBSSID() && bssid != nil {//局域网 return URL(string: "\(sa_lan_address ?? "")/api")! } else if AuthManager.shared.isLogin && id != nil { return URL(string: temporaryIP)! } else { if let url = URL(string: "\(sa_lan_address ?? "http://")") { return url } return URL(string: "http://")! } }
}
登录授权
class AuthManager { ... /// 是否登录云端账号 var isLogin: Bool { get { return UserDefaults.standard.bool(forKey: "zhiting.userDefault.isLogin") } set { UserDefaults.standard.setValue(newValue, forKey: "zhiting.userDefault.isLogin") UserDefaults.standard.synchronize() } } ...}
通过上述代码,整理以下重要的属性:
属性名称 | 代码属性 | 描述 |
---|---|---|
家庭的Id | Area.id | id=nil表示本地家庭 |
家庭的SA绑定状态 | Area.is_bind_sa | 判断当前家庭是否绑定SA |
家庭的SA授权 | Area.sa_user_token | 访问SA服务器的权限认证key |
家庭的SA的WiFi名称 | Area.ssid | 访问SA服务器的WiFi名称 |
家庭的SA地址 | Area.sa_lan_address | 访问SA服务器的Host地址;如果SC返回列表中该字段为空,即存在两种可能:1. SA未绑定 2. 未触发SA同步数据至SC |
家庭的MAC地址 | Area.macAddr | 当前家庭已绑SA的网络环境,判断是否同个局域网的依据 |
家庭归属SC账号Id | Area.cloud_user_id | 目的是判断当前家庭是否归属当前登录SC账号 |
是否SC登录 | AuthManager.isLogin | 判断当前用户(已绑定SC)是否登录,触发iOS端与SC数据同步的依据 |
基于上述属性判断,我们可以整理出上述3大类请求的条件判断:
类别 | 判断依据 | 请求服务器 |
---|---|---|
终端存储(本地) | Area.is_bind_sa == false && AuthManager.isLogin == false | 本地 |
室内SA请求 | Area.macAddr == 当前WiFi环境的bssid | SA |
室外SC中转 | Area.id != nil && Area.macAddr != 当前WiFi环境的bssid && AuthManager.isLogin == true | SC |
#
2.9.3.1 终端存储(本地)条件:Area.is_bind_sa = false && AuthManager.isLogin = false
以下操作本地数据库存储:
- 家庭/公司新增
- 家庭/公司修改、删除 (符合判断条件)
- 家庭/公司(符合判断条件)下房间区域新增、修改、删除
- 个人信息修改
#
2.9.3.2 室内SA请求条件:Area.macAddr = 当前WiFi环境的bssid
API请求Header携带以下参数:
参数 | 值 |
---|---|
smart-assistant-token | Area.sa_user_token |
#
2.9.3.3 室外SC中转条件:Area.id != nil && Area.macAddr != 当前WiFi环境的bssid && AuthManager.isLogin = true
API请求Header携带以下参数:
参数 | 值 |
---|---|
smart-assistant-token | Area.sa_user_token |
Area-ID | Area.id |
附:专属于智汀云的接口
环境域名:https://sc.zhitingtech.com
接口名称 | Path | Method | 描述 |
---|---|---|---|
登录 | /sessions/login | POST | 登录智汀云 |
绑定云 | /users | POST | 智汀云注册用户账号 |
获取验证码 | /captcha | GET | 获取绑定云中的验证码 |
获取家庭/公司列表 | /areas | GET | 获取当前账号在智汀云关联的家庭/公司列表 |
#
2.9.4 各种活动情景下的数据同步逻辑我们清晰了情景的3大类,3大类很好的概述了家庭/公司在上述条件中的处理逻辑。
那么对于家庭/公司添加SA或者扫码加入家庭的活动中,存在着更多小分类活动,我们先根据下列表格,了解一下:
#
2.9.4.1 16小类活动情景名词解释:室内:与SA在同个局域网环境内 室外:与SA不在同个局域网环境内
序号 | 活动步骤 前置条件:家庭未绑定SA、未绑定SC | 绑定云 | 登录云 | 绑定SA | 扫码加入 | ||
---|---|---|---|---|---|---|---|
室内 | 室外 | 室内 | 室内 | 室外 | |||
1. | 家庭无关联SA、SC情况 | -- | -- | -- | -- | -- | -- |
2. | 家庭绑定SA且无关联SC 步骤1:家庭绑定SA | -- | -- | -- | step1 | -- | -- |
3. | 室内扫码加入SA家庭且无关联SC 步骤1:扫描加入SA家庭 | -- | -- | -- | -- | step1 | -- |
4. | 在登录SC后绑定SA 步骤1:绑定SC 步骤2:登录SC 步骤3:室内绑定SA | step1 | step2 | step3 | -- | -- | |
5. | 退出SC登录状态下绑定SA,室内再登录SC 步骤1:绑定SC 步骤2:室内绑定SA 步骤3:室内登录SC | step1 | step3 | -- | step2 | -- | -- |
6. | 退出SC登录状态下绑定SA,室外再登录SC 步骤1:绑定SC 步骤2:室内绑定SA 步骤3:室外登录SC | step1 | -- | step3 | step2 | -- | -- |
7. | 空SC用户状态下绑定SA, 然后绑定SC, 室内登录SC 步骤1:室内绑定SA 步骤2:绑定SC 步骤3:室内登录SC | step2 | step3 | -- | step1 | -- | -- |
8. | 空SC用户状态下绑定SA, 然后绑定SC, 室外登录SC 步骤1:室内绑定SA 步骤2:绑定SC 步骤3:室外登录SC | step2 | -- | step3 | step1 | -- | -- |
9. | 已登录SC状态下室内扫码加入SA家庭 步骤1:绑定SC 步骤2:登录SC 步骤3:室内扫码加入SA家庭 | step1 | step2 | -- | step3 | -- | |
10. | 已登录SC状态下室外扫码加入SA家庭 步骤1:绑定SC 步骤2:登录SC 步骤3:室外扫码加入SA家庭 | step1 | step2 | -- | -- | step3 | |
11. | 退出SC登录状态下室内扫码加入SA家庭,然后室内登录SC 步骤1:绑定SC 步骤2:室内扫码加入SA家庭 步骤3:室内登录SC | step1 | step3 | -- | -- | step2 | -- |
12. | 退出SC登录状态下室内扫码加入SA家庭,然后室外登录SC 步骤1:绑定SC 步骤2:室内扫码加入SA家庭 步骤3:室外登录SC | step1 | -- | step3 | -- | step2 | -- |
13. | 退出SC登录状态下室外扫码加入SA家庭,然后室内登录SC 步骤1:绑定SC 步骤2:室外扫码加入SA家庭 步骤3:室内登录SC | step1 | step3 | -- | -- | -- | step2 |
14. | 退出SC登录状态下室外扫码加入SA家庭,然后室外登录SC 步骤1:绑定SC 步骤2:室外扫码加入SA家庭 步骤3:室外登录SC | step1 | -- | step3 | -- | -- | step2 |
15. | 空SC用户状态下室内扫码加入SA家庭, 然后绑定SC, 室内登录SC 步骤1:室内扫码加入SA家庭 步骤2:绑定SC 步骤3:室内登录SC | step2 | step3 | -- | -- | step1 | -- |
16. | 空SC用户状态下室内扫码加入SA家庭, 然后绑定SC, 室外登录SC 步骤1:室内扫码加入SA家庭 步骤2:绑定SC 步骤3:室外登录SC | step2 | -- | step3 | -- | step1 | -- |
#
2.9.4.2 16小类活动情景分析上述的各种活动情景,主要产生的问题在于iOS终端、SA、SC的数据同步,体现在家庭数据上。
我们先回顾一下家庭表的设计,跟上述活动情景息息相关的字段如下:
名称 | 类型 | 是否null | 注释 |
---|---|---|---|
id | integer | 否 | id |
sa_user_id | integer | 用户Id | |
sa_user_token | string | 用户SA认证授权 | |
is_bind_sa | bool | 否 | 家庭/公司是否绑定SA |
ssid | string | sa的wifi名称 | |
sa_lan_address | string | sa的地址 | |
macAddr | string | sa的mac地址 | |
cloud_user_id | integer | 云端用户的user_id |
1)在数据同步至SC时,以下字段获得赋值:
Area.id
Area.cloud_user_id
2) 在绑定SA、扫描加入SA家庭(室内/室外)时,以下字段获得赋值:
Area.sa_user_id
Area.sa_user_token
Area.is_bind_sa
Area.sa_lan_address
3)在室内连接上SA时,以下字段获得赋值:
Area.ssid
Area.macAddr
综上,iOS端的处理思路就可以清晰的梳理为:
- 登录事件处理
- 登录状态获取家庭列表事件处理(考虑多终端操作家庭数据同步)
- 绑定SA事件处理
- 扫码加入SA家庭事件处理
- 家庭列表切换家庭事件处理(需要对选中家庭的网络判断)
- 网络环境变化事件处理
#
2.9.4.3 16小类活动情景处理方法#
1)登录事件处理请求服务器:SC服务器
处理逻辑:登录成功获取SC.user_id
(1). 清空非归属当前登录账号的家庭及相关信息
条件筛选:Area.cloud_user_id > 0 && Area.cloud_user_id != SC.user_id
(2). 获取SC的家庭列表信息,并同步到本地数据库存储
条件筛选:SC.Area.id not in Area.id
(3). 同步本地待同步的家庭信息至SC
情况1:请求SA将当前SA环境的家庭绑定到SC
条件筛选: Area.id == 0 && Area.is_bind_sa == true && Area.macAddr = 当前WiFi环境的bssid
情况2:其余待同步数据直接同步至SC
条件筛选:Area.id == 0
代码如下:AuthManager类
/// Login /// - Parameters: /// - phone: phone /// - pwd: password /// - success: success callback /// - failure: failure callback func logIn(phone: String, pwd: String, success: ((User) -> Void)?, failure: ((String) -> Void)?) { ApiServiceManager.shared.login(phone: phone, password: pwd) { [weak self] (response) in guard let self = self else { return } let user = response.user_info self.isLogin = true self.currentUser.icon_url = user.icon_url self.currentUser.phone = user.phone self.currentUser.user_id = user.user_id if user.nickname != "" { self.currentUser.nickname = user.nickname } UserCache.update(from: user) let needCleanArea = AreaCache.areaList().filter({ $0.cloud_user_id != user.user_id })
needCleanArea.forEach { if $0.cloud_user_id > 0 { AreaCache.deleteArea(id: $0.id, sa_token: $0.sa_user_token) } } /// 登录成功后获取家庭列表 ApiServiceManager.shared.areaList { [weak self] response in guard let self = self else { return } response.areas.forEach { $0.cloud_user_id = self.currentUser.user_id } AreaCache.cacheAreas(areas: response.areas, needRemove: false) /// 如果该账户云端没有家庭则自动创建一个 if AreaCache.areaList().count == 0 { self.currentArea = AreaCache.createArea(name: "我的家", locations_name: [], sa_token: "unbind\(UUID().uuidString)", cloud_user_id: user.user_id).transferToArea() }
/// 同步本地家庭到云 self.syncLocalAreasToCloud(needUpdateCurrentArea: true) { success?(user) }
} failureCallback: { code, err in failure?(err) } } failureCallback: { (code, err) in failure?(err) }
}
func syncLocalAreasToCloud(needUpdateCurrentArea: Bool = false, finish: (() -> ())?) { /// 未同步到云端的家庭 let areas = AreaCache.areaList().filter { $0.id == nil || $0.cloud_user_id == -1 || $0.needRebindCloud } var finishTask = 0 if areas.count == 0 { if let area = AreaCache.areaList().last { if needUpdateCurrentArea { AuthManager.shared.currentArea = area } } finish?() return } areas.forEach { area in let locations = LocationCache.areaLocationList(area_id: area.id, sa_token: area.sa_user_token).map(\.name) syncAreaOperationQueue.addOperation { [weak self] in guard let self = self else { return } self.operationSemaphore.wait() DispatchQueue.main.async { [weak self] in guard let self = self else { return } /// 云端创建对应本地的无SA家庭 if area.id == nil { ApiServiceManager.shared.createArea(name: area.name, locations_name: locations) { [weak self] response in guard let self = self else { return } /// 如果同步的家庭是本地未绑定SA的家庭 清除本地同一个家庭数据 直接取云端家庭(家庭id为云端家庭id) AreaCache.deleteArea(id: area.id, sa_token: area.sa_user_token) area.id = response.id area.cloud_user_id = AuthManager.shared.currentUser.user_id area.sa_user_id = response.cloud_sa_user_info?.id ?? 0 area.sa_user_token = response.cloud_sa_user_info?.token ?? "" AreaCache.cacheArea(areaCache: area.toAreaCache()) finishTask += 1 if finishTask == areas.count { ///所有同步任务已完成时 if let area = AreaCache.areaList().last { if needUpdateCurrentArea { AuthManager.shared.currentArea = area } } finish?() } self.operationSemaphore.signal() } failureCallback: { [weak self] code, err in finishTask += 1 /// 同步到云端失败的家庭直接移除 AreaCache.deleteArea(id: area.id, sa_token: area.sa_user_token) if finishTask == areas.count { ///所有同步任务已完成时 if let area = AreaCache.areaList().last { if needUpdateCurrentArea { AuthManager.shared.currentArea = area } } finish?() } self?.operationSemaphore.signal() } } else { /// 云端创建对应本地的有SA家庭 /// 检查是否在对应家庭SA环境 self.checkIfSAAvailable(addr: area.sa_lan_address ?? "") { available in if available { /// 在SA环境 ApiServiceManager.shared.createArea(name: area.name, locations_name: locations) { [weak self] response in guard let self = self else { return } let cloudAreaId = response.id /// 如果同步的家庭是本地已绑定SA的家庭 尝试将家庭SA绑定到云端 self.bindSAToCloud(area: area, cloud_area_id: cloudAreaId) { [weak self] success in guard let self = self else { return } if success { /// 如果绑定成功 /// 清除一下原本地家庭数据 AreaCache.deleteArea(id: area.id, sa_token: area.sa_user_token) area.cloud_user_id = AuthManager.shared.currentUser.user_id area.ssid = NetworkStateManager.shared.getWifiSSID() area.bssid = NetworkStateManager.shared.getWifiBSSID() area.needRebindCloud = false /// (家庭id为SA家庭id) AreaCache.cacheArea(areaCache: area.toAreaCache()) finishTask += 1 if finishTask == areas.count { ///所有同步任务已完成时 if let area = AreaCache.areaList().last { if needUpdateCurrentArea { AuthManager.shared.currentArea = area } } finish?() } self.operationSemaphore.signal() } else { /// 绑定SA到云失败 area.cloud_user_id = AuthManager.shared.currentUser.user_id area.needRebindCloud = true AreaCache.cacheArea(areaCache: area.toAreaCache()) /// 绑定失败时将刚创建的云端家庭删掉 let deleteArea = Area() deleteArea.id = response.id ApiServiceManager.shared.deleteArea(area: deleteArea, isDeleteDisk: false, successCallback: nil, failureCallback: nil) finishTask += 1 if finishTask == areas.count { ///所有同步任务已完成时 if let area = AreaCache.areaList().last { if needUpdateCurrentArea { AuthManager.shared.currentArea = area } } finish?() } self.operationSemaphore.signal() } } } failureCallback: { [weak self] code, err in finishTask += 1 /// 同步到云端失败的家庭直接移除 AreaCache.deleteArea(id: area.id, sa_token: area.sa_user_token) if finishTask == areas.count { ///所有同步任务已完成时 if let area = AreaCache.areaList().last { if needUpdateCurrentArea { AuthManager.shared.currentArea = area } } finish?() } self?.operationSemaphore.signal() } } else { /// 不在SA环境 finishTask += 1 /// 绑定SA到云失败 area.cloud_user_id = AuthManager.shared.currentUser.user_id area.needRebindCloud = true AreaCache.cacheArea(areaCache: area.toAreaCache()) if finishTask == areas.count { ///所有同步任务已完成时 if let area = AreaCache.areaList().last { if needUpdateCurrentArea { AuthManager.shared.currentArea = area } } finish?() } self.operationSemaphore.signal() } } } } } } }
/// 将家庭SA绑定到云端 /// - Parameters: /// - area: 本地SA家庭 /// - cloud_area_id: 云端家庭id /// - result: 绑定结果回调 func bindSAToCloud(area: Area, cloud_area_id: String, result: ((_ isSuccess: Bool) -> Void)?) { /// 若家庭没绑定SA 直接返回 guard area.is_bind_sa else { result?(false) return } checkIfSAAvailable(addr: area.sa_lan_address ?? "") { available in if available { /// 在SA环境 ApiServiceManager.shared.bindCloud(area: area, cloud_area_id: cloud_area_id, cloud_user_id: AuthManager.shared.currentUser.user_id, url: area.sa_lan_address ?? "") { response in area.bssid = NetworkStateManager.shared.getWifiBSSID() area.ssid = NetworkStateManager.shared.getWifiSSID() result?(true) } failureCallback: { code, err in result?(false) }
} else { /// 不在SA环境 result?(false) } } }
/// 检测请求地址是否在对应的SA环境 /// - Parameters: /// - addr: 地址 /// - resultCallback: 结果回调 func checkIfSAAvailable(addr: String, resultCallback: ((_ available: Bool) -> Void)?) { if let url = URL(string: "\(addr)/api/check") { var request = URLRequest(url: url) request.timeoutInterval = 0.5 request.httpMethod = "POST"
URLSession(configuration: .default) .dataTask(with: request) { (data, response, error) -> Void in guard error == nil else { DispatchQueue.main.async { resultCallback?(false) } return } guard (response as? HTTPURLResponse)?.statusCode == 200 else { DispatchQueue.main.async { resultCallback?(false) } return } DispatchQueue.main.async { resultCallback?(true) } } .resume() } else { DispatchQueue.main.async { resultCallback?(false) } } }}
#
2)登录状态获取家庭列表事件处理请求服务器:SC服务器
处理逻辑:前置条件:AuthManager.isLogin = true
(1). 获取SC的家庭列表信息,并同步到本地数据库存储
条件筛选:SC.Area.id not in Area.id
(2). 本地存储移除SC已删除的家庭信息
条件筛选:Area.id not in SC.Area.id
(3). 请求SA将当前SA环境的家庭绑定到SC
条件筛选: Area.is_bind_sa == true && SC.Area.sa_lan_address == "" && Area.macAddr = 当前WiFi环境的bssid
代码如下:AreaListViewController类
@objc func requestNetwork() { /// cache if !authManager.isLogin { tableView.mj_header?.endRefreshing() hideLodingView() areas = AreaCache.areaList() print("--------- local cache areas ---------") print(areas) print("-------------------------------------") tableView.reloadData() return } apiService.requestModel(.areaList, requestEnv: .cloud, allowSwitchEnv: false, modelType: AreaListReponse.self) { [weak self] (response) in guard let self = self else { return }
response.areas.forEach { $0.cloud_user_id = self.authManager.currentUser.user_id } AreaCache.cacheAreas(areas: response.areas) let areas = AreaCache.areaList() print("--------- local cache areas ---------") print(areas) print("-------------------------------------") /// 如果在对应的局域网环境下,将局域网内绑定过SA但未绑定到云端的家庭绑定到云端 let checkAreas = response.areas.filter({ !$0.is_bind_sa }) checkAreas.forEach { area in if let bindedArea = areas.first(where: { $0.id == area.id && $0.is_bind_sa }) { /// 如果在对应的局域网内 if self.dependency.networkManager.getWifiBSSID() == bindedArea.macAddr { self.apiService.requestModel(.bindCloud(area: bindedArea, cloud_user_id: self.authManager.currentUser.user_id), requestEnv: .sa, allowSwitchEnv: false, modelType: BaseModel.self, successCallback: nil, failureCallback: nil) } } } self.hideLodingView() self.tableView.mj_header?.endRefreshing() self.areas = areas self.tableView.reloadData() } failureCallback: { [weak self] (code, err) in guard let self = self else { return } self.hideLodingView() self.tableView.mj_header?.endRefreshing() self.areas = AreaCache.areaList() self.tableView.reloadData() } } }
#
3)绑定SA事件处理请求服务器:SA服务器
处理逻辑:
(1). 本地当前家庭/公司信息、房间/区域信息、用户信息同步至SA服务器
(2). 新增本地家庭信息(绑定SA相关信息、网络相关信息),清空同步前该家庭相关数据
(3). 已登录SC情况下,请求SA将当前SA环境的家庭绑定到SC
条件筛选:SC.Area.id not in Area.id
(2). 本地存储移除SC已删除的家庭信息
条件筛选:Area.id not in SC.Area.id
(3). 请求SA将当前SA环境的家庭绑定到SC
///扫描添加SA、智能设备 private func requestNetwork(device: DiscoverDeviceModel?) { guard let device = device else { return } startLoading() if device.model.contains("smart_assistant") { /// add SA apiService.requestModel(.addSADevice(url: device.address, device: device), modelType: ResponseModel.self) { [weak self] response in guard let self = self else { return } let success = response.device_id != -1 if success { self.syncLocalDataToSmartAssistant(info: response.user_info) } else { self.failToConnect() } } failureCallback: { [weak self] (code, err) in self?.failToConnect() } } else { /// add device ... } }
///同步本地数据至SA private func syncLocalDataToSmartAssistant(info: User) { let deleteToken = "\(authManager.currentArea.sa_user_token)" let deleteAreaId = authManager.currentArea.id
let realm = try! Realm() let areaSyncModel = SyncSAModel.AreaSyncModel() areaSyncModel.name = authManager.currentArea.name let locations = realm.objects(LocationCache.self).filter("area_id = \(authManager.currentArea.id) AND sa_user_token = '\(authManager.currentArea.sa_user_token)'") var sort = 1 locations.forEach { area in let locationSyncModel = SyncSAModel.LocationSyncModel() locationSyncModel.name = area.name locationSyncModel.sort = sort areaSyncModel.locations.append(locationSyncModel) sort += 1 } let syncModel = SyncSAModel() syncModel.area = areaSyncModel syncModel.nickname = authManager.currentUser.nickname let saArea = Area() saArea.sa_lan_address = device?.address ?? "" saArea.ssid = dependency.networkManager.getWifiSSID() saArea.macAddr = dependency.networkManager.getWifiBSSID() saArea.sa_user_token = info.token saArea.is_bind_sa = true saArea.sa_user_id = info.user_id saArea.name = authManager.currentArea.name saArea.id = self.area.id >= 1 ? self.area.id : 0 try? realm.write { realm.add(saArea.toAreaCache()) } self.authManager.currentArea = saArea apiService.requestModel(.syncArea(syncModel: syncModel), requestEnv: .sa, allowSwitchEnv: false, modelType: BaseModel.self) { [weak self] response in guard let self = self else { return } if let area = realm.objects(AreaCache.self).filter("sa_user_token = '\(deleteToken)'").first { let devices = realm.objects(DeviceCache.self).filter("area_id = \(deleteAreaId) AND sa_user_token = '\(deleteToken)'") let locations = realm.objects(LocationCache.self).filter("area_id = \(deleteAreaId) AND sa_user_token = '\(deleteToken)'") try? realm.write { realm.delete(devices) realm.delete(locations) realm.delete(area) } } if saArea.id > 0 { // 云端家庭情况下同步完数据到SA后 将SA家庭绑定到云端 self.apiService.requestModel(.bindCloud(area: saArea, cloud_user_id: self.authManager.currentUser.user_id), requestEnv: .sa, allowSwitchEnv: false, modelType: BaseModel.self) { [weak self] response in print("绑定成功") self?.finishLoadingSA() if let area = AreaCache.areaList().first(where: { $0.macAddr == self?.dependency.networkManager.getWifiBSSID() && $0.macAddr != nil }) { self?.authManager.currentArea = area } } failureCallback: { [weak self] code, err in print("绑定失败") self?.finishLoadingSA() if let area = AreaCache.areaList().first { self?.authManager.currentArea = area } } } else { self.finishLoadingSA() } } failureCallback: { [weak self] (code, err) in self?.failToConnect() } }
#
4)扫码加入SA家庭事件处理请求服务器:SA服务器 或 SC服务器
处理逻辑:
(1). 解析二维码信息
{ "qr_code":"xxxxxxxxxxx", "url":"http(s)://xxx:xxxx", "area_name":"xxxx"}
(2). 判断当前二维码适合哪种情况的请求? SA服务器请求 、SC服务器请求?
(3). 扫描加入SA家庭成功后,新增/更新该家庭至本地数据库(当家庭Area.sa_lan_address 存在时为更新)
///扫码加入家庭SA private func requestQRCodeResult(qr_code: String, sa_url: String, token: String?, area_name: String = "", area_id: Int) { let nickname = AppDelegate.shared.appDependency.authManager.currentUser.nickname var url = sa_url var requestEnv: RequestEnvironment = .sa if area_id > 0 && AppDelegate.shared.appDependency.authManager.isLogin { /// 家庭绑定了云端且已经登录的情况下走云 否则走sa url = "https://\(cloudUrl)" requestEnv = .cloud }
GlobalLoadingView.show() AppDelegate.shared.appDependency.apiService.requestModel( .scanQRCode(qr_code: qr_code, url: url, nickname: nickname, token: token, area_id: area_id), requestEnv: requestEnv, allowSwitchEnv: false, modelType: ScanResponse.self ) { [weak self] response in guard let self = self else { return }
let area = Area() if let ip = sa_url.components(separatedBy: "//").last { area.sa_lan_address = ip } if area_id > 0 && AppDelegate.shared.appDependency.authManager.isLogin { /// 家庭绑定了云端且已经登录的情况下area的id 为绑定云后id 否则为0 area.id = response.area_id ?? 0 } else { if let id = AreaCache.areaList().first(where: { $0.sa_user_token == response.user_info.token })?.id { area.id = id } else { area.id = 0 } } area.sa_user_id = response.user_info.user_id area.is_bind_sa = true area.sa_user_token = response.user_info.token area.name = area_name if requestEnv == .sa { area.ssid = AppDelegate.shared.appDependency.networkManager.getWifiSSID() area.macAddr = AppDelegate.shared.appDependency.networkManager.getWifiBSSID() } AreaCache.cacheArea(areaCache: area.toAreaCache()) if AppDelegate.shared.appDependency.authManager.isLogin && area.id == 0 { AppDelegate.shared.appDependency.authManager.syncLocalAreasToCloud { [weak self] in GlobalLoadingView.hide() guard let self = self else { return } if let switchArea = AreaCache.areaList().first(where: { $0.sa_user_token == response.user_info.token }) { AppDelegate.shared.appDependency.authManager.currentArea = switchArea } AppDelegate.shared.appDependency.tabbarController.homeVC?.view.makeToast("你已成功加入\(area_name)") self.navigationController?.tabBarController?.selectedIndex = 0 self.navigationController?.popToRootViewController(animated: false) } } else { GlobalLoadingView.hide() AppDelegate.shared.appDependency.tabbarController.homeVC?.needSwitchToCurrentSAArea = true AppDelegate.shared.appDependency.authManager.currentArea = area AppDelegate.shared.appDependency.tabbarController.homeVC?.view.makeToast("你已成功加入\(area_name)") self.navigationController?.tabBarController?.selectedIndex = 0 self.navigationController?.popToRootViewController(animated: false) } } failureCallback: { [weak self] (code, err) in GlobalLoadingView.hide() guard let self = self else { return } self.view.makeToast(err) self.startScan() } }
#
5)家庭列表切换家庭事件处理处理逻辑:获取选中家庭信息(本地)触发更新当前家庭信息
(1). 更新家庭网络情况(满足家庭已绑SA,但家庭的网络信息为空时更新)
条件筛选:Area.is_bind_sa == true && Area.sa_lan_address != "" && Area.ssid == "" && Area.macAddr == ""
检测网络:Area.sa_lan_address 是否在当前网络环境下能够请求
(2). 获取相应用户操作权限
代码如下:AuthManager类
/// 当前家庭切换后更新状态 func updateCurrentArea() { updateCurrentNickname() AppDelegate.shared.appDependency.websocket.disconnect() if isLogin { if let saAddr = currentArea.sa_lan_address, isSAEnviroment { AppDelegate.shared.appDependency.websocket.setUrl(urlString: "ws://\(saAddr)/ws", token: currentArea.sa_user_token) AppDelegate.shared.appDependency.websocket.connect() } else { AppDelegate.shared.appDependency.websocket.setUrl(urlString: "wss://\(cloudUrl)/ws", token: currentArea.sa_user_token) AppDelegate.shared.appDependency.websocket.connect() } } else { if let saAddr = currentArea.sa_lan_address { AppDelegate.shared.appDependency.websocket.setUrl(urlString: "ws://\(saAddr)/ws", token: currentArea.sa_user_token) AppDelegate.shared.appDependency.websocket.connect() } } if let addr = currentArea.sa_lan_address, currentArea.macAddr == nil { /// 检测请求地址是否在对应的SA环境 checkIfSAAvailable(addr: addr, sa_user_token: currentArea.sa_user_token) { [weak self] available in guard let self = self else { return } DispatchQueue.main.async { if available { ///更新本地数据家庭网络信息 self.currentArea.macAddr = self.networkStateManager.getWifiBSSID() self.currentArea.ssid = self.networkStateManager.getWifiSSID() AreaCache.cacheArea(areaCache: self.currentArea.toAreaCache()) } self.currentAreaPublisher.send(self.currentArea) /// 获取用户权限 self.getRolePermissions() } } } else { currentAreaPublisher.send(currentArea) /// 获取用户权限 getRolePermissions() } }
#
6)网络环境变化事件处理获取当前网络变化信息
采用发布订阅模式,发布网络变化信息,由订阅程序接收处理
1)处理更新当前家庭信息
代码如下:NetworkStateManager类
/// 网络变化信息reachabilityManager?.startListening(onUpdatePerforming: { [weak self] status in switch status { case .notReachable, .unknown: self?.networkState = .noNetwork self?.networkStatusPublisher.send(.noNetwork) case .reachable: self?.networkState = .reachable AppDelegate.shared.appDependency.websocket.connect() self?.networkStatusPublisher.send(.reachable) } AppDelegate.shared.appDependency.authManager.currentRolePermissions = RolePermission() DispatchQueue.main.asyncAfter(deadline: .now() + 1) { AppDelegate.shared.appDependency.authManager.getRolePermissions() } self?.currentWifiSSID = self?.getWifiSSID() self?.currentWifiMAC = self?.getWifiBSSID() print("当前wifi环境为: \(self?.currentWifiSSID ?? "nil")") })
/// 监听发布信息 networkStateManager.networkStatusPublisher .sink { [weak self] state in guard let self = self else { return } if state == .reachable { DispatchQueue.main.async { self.authManager.updateCurrentArea() } } } .store(in: &cancellables)
#
示例情景:活动13 退出SC登录状态下室外扫码加入SA家庭,然后室内登录SC1)识别二维码,判断访问服务端为SC
2)调用SC服务器API接口:POST:/invitation/check
3)扫码加入成功后,新增/更新该家庭至本地服务器
4)触发当前家庭切换后更新状态事件
(1). 更新家庭网络情况(满足家庭已绑SA,但家庭的网络信息为空时更新)
条件筛选:Area.is_bind_sa == true && Area.sa_lan_address != "" && Area.ssid == "" && Area.macAddr == ""
检测网络:Area.sa_lan_address 是否在当前网络环境下能够请求
(2). 获取相应用户操作权限
#
2.10 WebSocket模块#
2.10.1 WebSocket封装介绍项目中的使用的WebSocket是基于开源第三方库Starscream的再次封装,涉及到的类有三个,分别是:
- ZTWebSocketOperation:对WebSocket发送命令数据的封装,具体命令请看ZTWebsocket类的介绍;
- ZTWebSocketResponse:对WebSocket响应数据的封装,对WebSocket发出指令后接受到的响应使用对应的model解析;
- ZTWebsocket:WebSocket命令操作类;
#
2.10.1.1 ZTWebSocketOperationclass Operation: BaseModel { var domain = "" var id = 0 var service = "" var service_data = ServiceData()
init(domain: String, id: Int, service: String) { self.domain = domain self.id = id self.service = service } required init() { fatalError("init() has not been implemented") }}extension Operation { class ServiceData: BaseModel { var plugin_id: String? var device_id: Int? var power: String? }}
#
2.10.1.2 ZTWebSocketResponse// MARK: - WSOperationResponseclass WSOperationResponse<T: HandyJSON>: HandyJSON { var id = 0 var success = false var result: T? required init() {}}
class EmptyResultResponse: HandyJSON { required init() {}}
class SearchDeviceResponse: HandyJSON { var device = DiscoverDeviceModel() required init() {}}
class DeviceStatusResponse: HandyJSON { var state = DeviceStatus() required init() {} class DeviceStatus: HandyJSON { var device_id = "" var power = "off" var is_online = false var brightness = 0 var color_temp = 0 required init() {} } }
class DeviceActionResponse: HandyJSON { required init() {} var actions = DeviceAction() class DeviceDetailAction: HandyJSON { required init() {} var cmd = "" var name = "" var is_permit = false } class DeviceAction: HandyJSON { required init() {} var `switch`: DeviceDetailAction? var set_color_temp: DeviceDetailAction? var set_bright: DeviceDetailAction? }}
// MARK: - WSEventResponseclass WSEventResponse<T: HandyJSON>: HandyJSON { var event_type = "" var data: T? var origin = "" required init() {}}
class DeviceStatusEventResponse: HandyJSON { var device_id: Int = -1 var state = DeviceStatus() required init() {} class DeviceStatus: HandyJSON { var power: String? var is_online: Bool? required init() {} }}
#
2.10.1.3 ZTWebSocketimport UIKitimport Starscreamimport Combine
/// Websocket connect statusenum WebsocketConnectStatus { case connected case disconnected}
class ZTWebSocket { /// websocket private var socket: WebSocket! /// 当前连接的地址 var currentAddress: String?
/// connectStatus var status: WebsocketConnectStatus = .disconnected /// autoInrecement id (use for record operations) lazy var id = 0 /// id: (Operation, OperationType) lazy var operationDict = [Int: (op: Operation, opType: OperationType)]() /// 重连次数 lazy var reconnectCount = 0 /// 最大重连次数 let maxReconnectCount = 6 /// 是否打印日志 var printDebugInfo = true /// 提供给h5调用的websocket相关回调 /// 监听 WebSocket 连接打开事件 var h5_onSocketOpenCallback: (() -> ())? /// 监听 WebSocket 接受到服务器的消息事件 var h5_onSocketMessageCallback: ((String) -> ())? /// 监听 WebSocket 错误事件 var h5_onSocketErrorCallback: ((String) -> ())? /// 监听 WebSocket 连接关闭事件 var h5_onSocketCloseCallback: ((String) -> ())? /// publishers /// Socket连接成功publisher lazy var socketDidConnectedPublisher = PassthroughSubject<Void, Never>() /// SA发现设备publisher lazy var discoverDevicePublisher = PassthroughSubject<DiscoverDeviceModel, Never>() /// 设备状态\属性publisher lazy var deviceStatusPublisher = PassthroughSubject<DeviceStatusResponse, Never>() /// 设备状态改变publisher lazy var deviceStatusChangedPublisher = PassthroughSubject<DeviceStateChangeResponse, Never>() /// 插件安装回调publisher lazy var installPluginPublisher = PassthroughSubject<(plugin_id: String, success: Bool), Never>() /// 设备开关操作成功 publisher lazy var devicePowerPublisher = PassthroughSubject<(power: Bool, identity: String), Never>() /// 设置homekit设备pin码响应 publisher lazy var setHomekitCodePublisher = PassthroughSubject<(identity: String, success: Bool), Never>()
init(urlString: String = "ws://") { var request = URLRequest(url: URL(string: urlString)!) request.timeoutInterval = 30 socket = WebSocket(request: request) socket.delegate = self }
}
extension ZTWebSocket { func connect() { socket.connect() } /// 设置websocket连接 /// - Parameters: /// - urlString: 地址 /// - token: 家庭token func setUrl(urlString: String, token: String) { var request = URLRequest(url: URL(string: urlString)!) request.timeoutInterval = 30 request.setValue(token, forHTTPHeaderField: "smart-assistant-token") currentAddress = urlString /// 云端时websocket请求头带上对应家庭的id if AuthManager.shared.isLogin { request.setValue(AuthManager.shared.currentArea.id ?? "", forHTTPHeaderField: "Area-ID") } let pinner = FoundationSecurity(allowSelfSigned: true) // don't validate SSL certificates socket = WebSocket(request: request, certPinner: pinner) socket.delegate = self } /// 设置websocket连接 /// - Parameters: /// - urlString: 地址 /// - headers: headers func setUrl(urlString: String, headers: [String: Any]) { var request = URLRequest(url: URL(string: urlString)!) request.timeoutInterval = 30 headers.keys.forEach { key in request.setValue("\(headers[key] ?? "")", forHTTPHeaderField: key) } currentAddress = urlString let pinner = FoundationSecurity(allowSelfSigned: true) // don't validate SSL certificates socket = WebSocket(request: request, certPinner: pinner) socket.delegate = self } func writeString(str: String) { socket.write(string: str, completion: nil) }
func disconnect() { socket.disconnect() }}
// MARK: - OperationType & EventTypeextension ZTWebSocket { /// 操作指令 enum OperationType { /// SA发现设备 case discoverDevice(domain: String) /// 安装插件 case installPlugin(plugin_id: String) /// 升级插件 case updatePlugin(plugin_id: String) /// 移除插件 case removePlugin(plugin_id: String) /// 获取设备属性 case getDeviceAttributes(domain: String, identity: String) /// 控制设备开关 case controlDevicePower(domain: String, identity: String, instance_id: Int, power: Bool) /// 设置设备homekit码 case setDeviceHomeKitCode(domain: String, identity: String, instance_id: Int, code: String)
}
enum EventType: String { /// 收到设备状态变化 case attribute_change } }
// MARK: - ExcueteOperationsextension ZTWebSocket { /// execute operation /// - Parameter operation: operation type /// - Returns: nil func executeOperation(operation: OperationType) { var op: Operation! var opType: OperationType! switch operation { case .discoverDevice(let domain): op = Operation(domain: domain, id: id, service: "discover") opType = .discoverDevice(domain: domain) case .installPlugin(let plugin_id): op = Operation(domain: "plugin", id: id, service: "install") op.service_data.plugin_id = plugin_id opType = .installPlugin(plugin_id: plugin_id) case .updatePlugin(let plugin_id): op = Operation(domain: "plugin", id: id, service: "install") op.service_data.plugin_id = plugin_id opType = .updatePlugin(plugin_id: plugin_id) case .removePlugin(let plugin_id): op = Operation(domain: "plugin", id: id, service: "remove") op.service_data.plugin_id = plugin_id opType = .removePlugin(plugin_id: plugin_id) case .deviceStatus(let domain, let device_id): op = Operation(domain: domain, id: id, service: "state") op.service_data.device_id = device_id opType = .deviceStatus(domain: domain, device_id: device_id) case .turnOnDevice(let domain, let device_id): op = Operation(domain: domain, id: id, service: "switch") op.service_data.device_id = device_id op.service_data.power = "on" opType = .turnOnDevice(domain: domain, device_id: device_id) case .turnOffDevice(domain: let domain, device_id: let device_id): op = Operation(domain: domain, id: id, service: "switch") op.service_data.device_id = device_id op.service_data.power = "off" opType = .turnOffDevice(domain: domain, device_id: device_id) case .getDeviceActions(let domain, let device_id): op = Operation(domain: domain, id: id, service: "get_actions") op.service_data.device_id = device_id opType = .getDeviceActions(domain: domain, device_id: device_id) } if let data = op.toData() { operationDict[id] = (op, opType) id += 1 socket.write(data: data) wsLog("operation executed.\n\(op.toJSONString(prettyPrint: true) ?? "")") } } }
// MARK: - WebSocketDelegateextension ZTWebSocket: WebSocketDelegate { func didReceive(event: WebSocketEvent, client: WebSocket) { switch event { case .connected(let headers): status = .connected wsLog("websocket is connected: \(headers)") reconnectCount = 0 socketDidConnectedPublisher.send() case .disconnected(let reason, let code): status = .disconnected wsLog("websocket is disconnected: \(reason) with code: \(code)") reconnect() case .text(let string): handleReceived(string: string) case .binary(let data): wsLog("Received data: \(data.count) \n \(String(data: data, encoding: .utf8) ?? "")") case .ping(_): break case .pong(_): break case .viabilityChanged(_): break case .reconnectSuggested(_): reconnect() case .cancelled: break case .error(let error): status = .disconnected handleError(error) reconnect() } } private func reconnect() { id = 0 operationDict.removeAll() if reconnectCount > maxReconnectCount { return } reconnectCount += 1 DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in guard let self = self else { return } if self.status == .disconnected && AppDelegate.shared.appDependency.authManager.isSAEnviroment { self.socket.connect() } } } private func handleError(_ error: Error?) { if let e = error as? WSError { wsLog("websocket encountered an error: \(e.message)") } else { wsLog("websocket encountered an error \(String(describing: error?.localizedDescription))") } } }
// MARK: - Response Handlerextension ZTWebSocket { private func handleReceived(string: String) { wsLog("\(string)") guard let data = string.data(using: .utf8), let dict = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers) as? [String: Any] else { wsLog("failed to transform operation data to json.") return } /// operation response if let id = dict["id"] as? Int, let opType = operationDict[id]?.opType, let op = operationDict[id]?.op { handleOperationResponse(jsonString: string, operationType: opType, operation: op) } /// event response if let eventType = dict["event_type"] as? String, let type = EventType(rawValue: eventType) { handleEventResponse(type: type, jsonString: string) } } private func handleOperationResponse(jsonString: String, operationType: OperationType, operation: Operation) { switch operationType { case .discoverDevice: guard let response = WSOperationResponse<SearchDeviceResponse>.deserialize(from: jsonString), let device = response.result?.device else { return } discoverDevicePublisher.send(device) case .installPlugin: guard let response = WSOperationResponse<EmptyResultResponse>.deserialize(from: jsonString) else { return } if let pluginId = operation.service_data.plugin_id { installPluginPublisher.send((plugin_id: pluginId, success: response.success)) } case .updatePlugin: guard let response = WSOperationResponse<EmptyResultResponse>.deserialize(from: jsonString) else { return } if let pluginId = operation.service_data.plugin_id { installPluginPublisher.send((plugin_id: pluginId, success: response.success)) } case .removePlugin: break case .deviceStatus: guard let response = WSOperationResponse<DeviceStatusResponse>.deserialize(from: jsonString), let status = response.result?.state else { return } let power = status.power deviceStatusPublisher.send((operation_id: operation.id, is_online: status.is_online, power: power == "on"))
case .turnOnDevice: break case .turnOffDevice: break case .getDeviceActions: guard let response = WSOperationResponse<DeviceActionResponse>.deserialize(from: jsonString), let result = response.result else { return } deviceActionsPublisher.send((operation_id: operation.id, response: result)) } } private func handleEventResponse(type: EventType, jsonString: String) { switch type { case .state_changed: guard let response = WSEventResponse<DeviceStatusEventResponse>.deserialize(from: jsonString), let status = response.data else { return } let power = status.state.power ?? "" deviceStatusChangedPublisher.send((device_id: status.device_id, is_online: status.state.is_online, power: power == "on")) } } }
// MARK: - Helperextension ZTWebSocket { private func wsLog(_ item: Any) { if !printDebugInfo { return } print("------------------------< WebSocketLog >-----------------------------------") print("[\(socket.request.url?.absoluteString ?? "")]") print("---------------------------------------------------------------------------") print(Date()) print("---------------------------------------------------------------------------") print(item) print("---------------------------------------------------------------------------\n\n") } private func mapDataToModel<T: HandyJSON>(data: Data, type: T.Type) -> WSOperationResponse<T>? { let jsonString = String(data: data, encoding: .utf8) let model = WSOperationResponse<T>.deserialize(from: jsonString) return model }}
#
2.10.2 WebSocket连接在WebSocket连接之前,首先要调用 setUrl(urlString: String, token: String) 方法,设置家庭 token 连接时调用 connect() 方法。
#
2.10.3 WebSocket发送指令发送的指令类型封装成了一个enum枚举类OperationType
enum OperationType { case discoverDevice(domain: String) case installPlugin(plugin_id: String) case updatePlugin(plugin_id: String) case removePlugin(plugin_id: String) case deviceStatus(domain: String, device_id: Int) case turnOnDevice(domain: String, device_id: Int) case turnOffDevice(domain: String, device_id: Int) case getDeviceActions(domain: String, device_id: Int) ... }
使用时执行 executeOperation(operation: OperationType) 方法即可,例如:
/// 打开设备开关websocket.executeOperation(operation: .turnOnDevice(domain: domain, device_id: device.id))
#
2.10.4 WebSocket接收指令响应接收到指令响应后根据指令类型作出对应操作。
// MARK: - Response Handlerextension ZTWebSocket { private func handleReceived(string: String) { wsLog("\(string)") guard let data = string.data(using: .utf8), let dict = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers) as? [String: Any] else { wsLog("failed to transform operation data to json.") return } /// operation response if let id = dict["id"] as? Int, let opType = operationDict[id]?.opType, let op = operationDict[id]?.op { handleOperationResponse(jsonString: string, operationType: opType, operation: op) } /// event response if let eventType = dict["event_type"] as? String, let type = EventType(rawValue: eventType) { handleEventResponse(type: type, jsonString: string) } } private func handleOperationResponse(jsonString: String, operationType: OperationType, operation: Operation) { switch operationType { case .discoverDevice: guard let response = WSOperationResponse<SearchDeviceResponse>.deserialize(from: jsonString), let device = response.result?.device else { return } discoverDevicePublisher.send(device) case .installPlugin: guard let response = WSOperationResponse<EmptyResultResponse>.deserialize(from: jsonString) else { return } if let pluginId = operation.service_data.plugin_id { installPluginPublisher.send((plugin_id: pluginId, success: response.success)) } case .updatePlugin: guard let response = WSOperationResponse<EmptyResultResponse>.deserialize(from: jsonString) else { return } if let pluginId = operation.service_data.plugin_id { installPluginPublisher.send((plugin_id: pluginId, success: response.success)) } case .removePlugin: break case .deviceStatus: guard let response = WSOperationResponse<DeviceStatusResponse>.deserialize(from: jsonString), let status = response.result?.state else { return } let power = status.power deviceStatusPublisher.send((operation_id: operation.id, is_online: status.is_online, power: power == "on"))
case .turnOnDevice: break case .turnOffDevice: break case .getDeviceActions: guard let response = WSOperationResponse<DeviceActionResponse>.deserialize(from: jsonString), let result = response.result else { return } deviceActionsPublisher.send((operation_id: operation.id, response: result)) ... } } private func handleEventResponse(type: EventType, jsonString: String) { switch type { case .state_changed: guard let response = WSEventResponse<DeviceStatusEventResponse>.deserialize(from: jsonString), let status = response.data else { return } let power = status.state.power ?? "" deviceStatusChangedPublisher.send((device_id: status.device_id, is_online: status.state.is_online, power: power == "on")) ... } } }
#
3. 附录#
3.1 iOS数据库设计文档数据库: Realm
版本号: 10.7.0
使用参考手册:https://www.jianshu.com/p/02b2d50d11ba
数据表设计:
AreaCache 家庭/公司表
名称 | 类型 | 是否null | 注释 |
---|---|---|---|
id | string | 家庭id | |
name | string | 否 | 家庭/公司名称 |
sa_user_id | integer | 用户Id | |
sa_user_token | string | 用户SA认证授权 | |
is_bind_sa | bool | 否 | 家庭/公司是否绑定SA |
ssid | string | sa的wifi名称 | |
sa_lan_address | string | sa的地址 | |
macAddr | string | sa的mac地址 | |
setAccount | bool | 否 | 是否已经设置SA专业版账号 |
accountName | string | SA专业版账号名 | |
cloud_user_id | integer | 云端用户的user_id |
LocationCache 房间/区域表
名称 | 类型 | 是否null | 注释 |
---|---|---|---|
id | integer | 否 | 主键 |
name | string | 否 | 房间/区域名称 |
area_id | stirng | 家庭/公司Id | |
sort | integer | 否 | 排序编号 |
DeviceCache 设备表
名称 | 类型 | 是否null | 注释 |
---|---|---|---|
id | integer | 否 | 主键 |
name | string | 否 | 设备名称 |
model | string | 否 | 设备型号 |
brand_id | string | 品牌Id | |
logo_url | string | 设备Logo | |
identity | string | 设备属性 | |
plugin_id | string | 品牌插件Id | |
area_id | string | 家庭/公司Id | |
location_id | integer | 房间/区域Id | |
is_sa | bool | 否 | 是否SA设备 |
SceneCache 场景表
名称 | 类型 | 是否null | 注释 |
---|---|---|---|
id | integer | 否 | 主键 |
name | string | 否 | 场景名称 |
control_permission | bool | 否 | 修改场景状态权限 |
is_on | bool | 否 | 自动场景是否启动 |
type | integer | 触发条件类型;1为定时任务, 2为设备 | |
logo_url | string | 触发条件为设备时返回设备图片url | |
status | string | 设备状态:1正常2已删除3离线 | |
is_auto | integer | 否 | 是否自动 |
SceneItemCache 场景执行条件关联表
名称 | 类型 | 是否null | 注释 |
---|---|---|---|
id | integer | 否 | 主键 |
type | string | 否 | 执行任务类型;1为设备,2为场景 |
logo_url | string | 设备图片 | |
status | bool | 否 | 状态;1为正常,2为已删除,3为离线 |
scene_id | integer | 否 | 场景Id |
UserCache 用户表
名称 | 类型 | 是否null | 注释 |
---|---|---|---|
id | integer | 否 | 主键 |
nickname | string | 否 | 用户昵称 |
phone | string | 手机号 | |
icon_url | string | 用户头像 | |
user_id | integer | SA用户Id |