Skip to main content
iOS端开发指南

引言#

智汀家庭云iOS版 是一款在 Swift 5 中开发的 iOS智能家居APP。该应用一直在积极升级以采用 iOS和 Swift 语言的最新功能。此应用可以发现和连接家庭网络内符合相对应协议的终端产品,并基于这些产品打造接地气的生活场景,提供人性化的信息提示和交互,以及便捷的配套服务。

  1. 轻松控制设备

    您可以方便地调节智能灯的亮度和色温、智能控制开关插座、智能窗帘、空调的温度等等,即使不在家也能远程控制家里的智能设备

  2. 查看设备运行状况

    您可以在APP上查看每个设备的运行状态,是否开启或关闭。

  3. 设置相应的控制场景

1. 快速上手#

1.1 开发工具#

  • 当前版本适用于 Xcode 版本 Xcode 13 。如果您使用不同的 Xcode 版本,请查看之前的版本。
  • 此版本为仅使用 Swift 5 支持 iOS 13+。

1.2 源码地址#

  1. Git Hub

    名称URL描述
    sa-ios-sdkhttps://github.com/zhiting-tech/sa-ios-sdkiOS源码
  2. gitee

    名称URL描述
    sa-ios-sdkhttps://gitee.com/zhiting-tech/sa-ios-sdkiOS源码

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来设计与开发。

其中几个比较重要的Classes:
  1. Caches:本地化存储(Realm
  2. Network:网络层协议(MoyaAlamofire
  3. Vendors:智能设备置网(BluFi , SoftAP

2.3 Caches:本地化存储篇#

智汀家庭云iOS版 项目的本地化存储我们采用的是Realm数据库进行存储。

Realm优势:

  1. 兼顾iOS和Android两个平台;
  2. 简单易用,学习成本低;
  3. 提供了一个轻量级的数据库查看工具,开发者可以查看数据库当中的内容,执行简单的插入和删除数据的操作。

Realm支持事务,满足ACID:

  1. 原子性(Atomicity)
  2. 一致性(Consistency)
  3. 隔离性(Isolation)
  4. 持久性(Durability)。

2.3.1 RealmSwift安装#

  • CocoaPods导入

    pod 'RealmSwift'pod install
  • 导入头文件

    import RealmSwift
  • 封装文件路径:/Classes/Caches/LocalCache.swift

2.3.2 数据库操作#

LocalCache.swift文件内总共有6份表格,分别是:

  1. AraCache
  2. LocationCache
  3. DeviceCache
  4. SceneCache
  5. SceneItemCache
  6. 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 智能设备置网#

参照:Vendors:智能设备置网篇

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版,使用WKWebViewWKScriptMessageHandler实现Web端专业版集成。

WKWebView是Apple在iOS8推出的WebKit框架中的负责网页的渲染与展示的类,相比UIWebView速度更快,占用内存更少,支持更多的HTML特性。WKScriptMessageHandlerWebKit提供的一种在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调用iOS#

WKEventHandlerProtocol代理方法:JS与iOS定义好方法名称,方便JS进行调用

//! 第一步:导入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使用WKUserContentControlleraddScriptMessageHandler: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 活动情景分析#

图.室内、户外交互简图
**从上图中,我们可以知道iOS终端的活动情景支持3大类**:
  1. 终端存储
  2. 室内智慧中心(以下简称SA)请求
  3. 室外智汀云(以下简称SC)中转

本篇章的目的在于解决以下三大问题

  1. 解决存储数据同步问题
  2. 明确在什么条件下请求哪个服务端
  3. 各种活动场景下的数据同步逻辑

2.9.2 如何解决存储数据同步问题?#

在解决这个问题之前,我们先回忆一下之前篇章介绍的,iOS终端的本地数据库采用的是realm数据库,表设计结构如下:

realm本地数据库

表名描述
AreaCache家庭/公司表
LocationCache房间/区域表
DeviceCache设备表
SceneCache场景表
SceneItemCache场景任务关联表
UserCache用户表

-附录: iOS数据库设计文档

上述表设计中,体现以下几大功能数据的存储:

  1. 家庭/公司
  2. 房间/区域
  3. 设备
  4. 场景
  5. 个人信息

数据同步如下图示:

图:iOS终端与SA数据同步
图:iOS终端与SC单向数据同步
图:SA与SC单向数据同步
#### 2.9.3 如何判断在什么条件下请求哪个服务端?

在开始这个话题之前,我们先看看两段代码的设计:

家庭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()        }    }    ...}

通过上述代码,整理以下重要的属性:

属性名称代码属性描述
家庭的IdArea.idid=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账号IdArea.cloud_user_id目的是判断当前家庭是否归属当前登录SC账号
是否SC登录AuthManager.isLogin判断当前用户(已绑定SC)是否登录,触发iOS端与SC数据同步的依据

基于上述属性判断,我们可以整理出上述3大类请求的条件判断:

类别判断依据请求服务器
终端存储(本地)Area.is_bind_sa == false && AuthManager.isLogin == false本地
室内SA请求Area.macAddr == 当前WiFi环境的bssidSA
室外SC中转Area.id != nil && Area.macAddr != 当前WiFi环境的bssid && AuthManager.isLogin == trueSC
2.9.3.1 终端存储(本地)#

条件Area.is_bind_sa = false && AuthManager.isLogin = false

以下操作本地数据库存储:

  1. 家庭/公司新增
  2. 家庭/公司修改、删除 (符合判断条件)
  3. 家庭/公司(符合判断条件)下房间区域新增、修改、删除
  4. 个人信息修改
2.9.3.2 室内SA请求#

条件Area.macAddr = 当前WiFi环境的bssid

API请求Header携带以下参数:

参数
smart-assistant-tokenArea.sa_user_token
2.9.3.3 室外SC中转#

条件Area.id != nil && Area.macAddr != 当前WiFi环境的bssid && AuthManager.isLogin = true

API请求Header携带以下参数:

参数
smart-assistant-tokenArea.sa_user_token
Area-IDArea.id

附:专属于智汀云的接口

环境域名https://sc.zhitingtech.com

接口名称PathMethod描述
登录/sessions/loginPOST登录智汀云
绑定云/usersPOST智汀云注册用户账号
获取验证码/captchaGET获取绑定云中的验证码
获取家庭/公司列表/areasGET获取当前账号在智汀云关联的家庭/公司列表

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
step1step2step3----
5.退出SC登录状态下绑定SA,室内再登录SC
步骤1:绑定SC
步骤2:室内绑定SA
步骤3:室内登录SC
step1step3--step2----
6.退出SC登录状态下绑定SA,室外再登录SC
步骤1:绑定SC
步骤2:室内绑定SA
步骤3:室外登录SC
step1--step3step2----
7.空SC用户状态下绑定SA, 然后绑定SC, 室内登录SC
步骤1:室内绑定SA
步骤2:绑定SC
步骤3:室内登录SC
step2step3--step1----
8.空SC用户状态下绑定SA, 然后绑定SC, 室外登录SC
步骤1:室内绑定SA
步骤2:绑定SC
步骤3:室外登录SC
step2--step3step1----
9.已登录SC状态下室内扫码加入SA家庭
步骤1:绑定SC
步骤2:登录SC
步骤3:室内扫码加入SA家庭
step1step2--step3--
10.已登录SC状态下室外扫码加入SA家庭
步骤1:绑定SC
步骤2:登录SC
步骤3:室外扫码加入SA家庭
step1step2----step3
11.退出SC登录状态下室内扫码加入SA家庭,然后室内登录SC
步骤1:绑定SC
步骤2:室内扫码加入SA家庭
步骤3:室内登录SC
step1step3----step2--
12.退出SC登录状态下室内扫码加入SA家庭,然后室外登录SC
步骤1:绑定SC
步骤2:室内扫码加入SA家庭
步骤3:室外登录SC
step1--step3--step2--
13.退出SC登录状态下室外扫码加入SA家庭,然后室内登录SC
步骤1:绑定SC
步骤2:室外扫码加入SA家庭
步骤3:室内登录SC
step1step3------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
step2step3----step1--
16.空SC用户状态下室内扫码加入SA家庭, 然后绑定SC, 室外登录SC
步骤1:室内扫码加入SA家庭
步骤2:绑定SC
步骤3:室外登录SC
step2--step3--step1--
2.9.4.2 16小类活动情景分析#

上述的各种活动情景,主要产生的问题在于iOS终端、SA、SC的数据同步,体现在家庭数据上。

我们先回顾一下家庭表的设计,跟上述活动情景息息相关的字段如下:

名称类型是否null注释
idintegerid
sa_user_idinteger用户Id
sa_user_tokenstring用户SA认证授权
is_bind_sabool家庭/公司是否绑定SA
ssidstringsa的wifi名称
sa_lan_addressstringsa的地址
macAddrstringsa的mac地址
cloud_user_idinteger云端用户的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端的处理思路就可以清晰的梳理为:

  1. 登录事件处理
  2. 登录状态获取家庭列表事件处理(考虑多终端操作家庭数据同步)
  3. 绑定SA事件处理
  4. 扫码加入SA家庭事件处理
  5. 家庭列表切换家庭事件处理(需要对选中家庭的网络判断)
  6. 网络环境变化事件处理
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. 获取当前网络变化信息

  2. 采用发布订阅模式,发布网络变化信息,由订阅程序接收处理

    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家庭,然后室内登录SC#

1)识别二维码,判断访问服务端为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 ZTWebSocketOperation#
class 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 ZTWebSocket#
import 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注释
idstring家庭id
namestring家庭/公司名称
sa_user_idinteger用户Id
sa_user_tokenstring用户SA认证授权
is_bind_sabool家庭/公司是否绑定SA
ssidstringsa的wifi名称
sa_lan_addressstringsa的地址
macAddrstringsa的mac地址
setAccountbool是否已经设置SA专业版账号
accountNamestringSA专业版账号名
cloud_user_idinteger云端用户的user_id

LocationCache 房间/区域表

名称类型是否null注释
idinteger主键
namestring房间/区域名称
area_idstirng家庭/公司Id
sortinteger排序编号

DeviceCache 设备表

名称类型是否null注释
idinteger主键
namestring设备名称
modelstring设备型号
brand_idstring品牌Id
logo_urlstring设备Logo
identitystring设备属性
plugin_idstring品牌插件Id
area_idstring家庭/公司Id
location_idinteger房间/区域Id
is_sabool是否SA设备

SceneCache 场景表

名称类型是否null注释
idinteger主键
namestring场景名称
control_permissionbool修改场景状态权限
is_onbool自动场景是否启动
typeinteger触发条件类型;1为定时任务, 2为设备
logo_urlstring触发条件为设备时返回设备图片url
statusstring设备状态:1正常2已删除3离线
is_autointeger是否自动

SceneItemCache 场景执行条件关联表

名称类型是否null注释
idinteger主键
typestring执行任务类型;1为设备,2为场景
logo_urlstring设备图片
statusbool状态;1为正常,2为已删除,3为离线
scene_idinteger场景Id

UserCache 用户表

名称类型是否null注释
idinteger主键
nicknamestring用户昵称
phonestring手机号
icon_urlstring用户头像
user_idintegerSA用户Id