Improve Home Assistant connection reliability

This commit is contained in:
2026-07-27 16:23:40 -07:00
parent b7ec774efd
commit f3341ee064
16 changed files with 1696 additions and 697 deletions
@@ -13,7 +13,9 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
fileprivate var _serverMultiplex: ServerMultiplex = ServerMultiplex()
fileprivate var _visualizationController: VisualizationViewController = VisualizationViewController()
fileprivate var _headerView: HeaderView = HeaderView()
fileprivate var _updateDevices: Bool = false
fileprivate var _refreshTimer: Timer?
fileprivate var _isApplicationActive: Bool = false
fileprivate var _isViewVisible: Bool = false
fileprivate var _switchesController: SwitchesViewController = SwitchesViewController()
fileprivate var _lightsController: SwitchesViewController = SwitchesViewController()
@@ -58,6 +60,7 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
self.view.addSubview(_headerView)
_updateConnectivityStatus(.disconnected)
_updateSizeClassPresentation()
}
override func viewDidLayoutSubviews()
@@ -80,61 +83,68 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
_headerView.frame = headerBounds
let visualizationFrame = CGRect(
x: bodyBounds.origin.x,
y: bodyBounds.origin.y,
width: rint(0.5 * bodyBounds.size.width),
height: bodyBounds.size.height / 1.5
)
_visualizationController.view.frame = visualizationFrame
let lightsControllerFrame = CGRect(
x: bodyBounds.origin.x,
y: visualizationFrame.maxY,
width: visualizationFrame.width,
height: bodyBounds.height - visualizationFrame.height
)
_lightsController.view.frame = lightsControllerFrame.insetBy(dx: 18.0, dy: 0.0)
var switchesOriginX: CGFloat = 0.0
var switchesWidth: CGFloat = 0.0
if (_visualizationShouldBeVisible()) {
switchesOriginX = visualizationFrame.maxX
switchesWidth = bodyBounds.size.width - visualizationFrame.size.width
if _visualizationShouldBeVisible() {
let visualizationFrame = CGRect(
x: bodyBounds.origin.x,
y: bodyBounds.origin.y,
width: rint(0.5 * bodyBounds.size.width),
height: bodyBounds.size.height / 1.5
)
_visualizationController.view.frame = visualizationFrame
let lightsControllerFrame = CGRect(
x: bodyBounds.origin.x,
y: visualizationFrame.maxY,
width: visualizationFrame.width,
height: bodyBounds.height - visualizationFrame.height
)
_lightsController.view.frame = lightsControllerFrame.insetBy(
dx: 18.0,
dy: 0.0
)
_switchesController.view.frame = CGRect(
x: visualizationFrame.maxX,
y: bodyBounds.origin.y,
width: bodyBounds.width - visualizationFrame.width,
height: bodyBounds.height
)
} else {
switchesOriginX = 0.0
switchesWidth = bodyBounds.size.width
let sectionSpacing: CGFloat = 8
let switchesHeight = rint(bodyBounds.height * 0.68)
_visualizationController.view.frame = .zero
_switchesController.view.frame = CGRect(
x: bodyBounds.minX,
y: bodyBounds.minY,
width: bodyBounds.width,
height: switchesHeight
)
_lightsController.view.frame = CGRect(
x: bodyBounds.minX + sectionSpacing,
y: bodyBounds.minY + switchesHeight + sectionSpacing,
width: bodyBounds.width - (sectionSpacing * 2),
height: bodyBounds.height - switchesHeight - sectionSpacing
)
}
let switchesControllerFrame = CGRect(
x: switchesOriginX,
y: bodyBounds.origin.y,
width: switchesWidth,
height: bodyBounds.size.height
)
_switchesController.view.frame = switchesControllerFrame
_updateSizeClassPresentation()
}
override func viewDidAppear(_ animated: Bool)
{
super.viewDidAppear(animated)
UIApplication.shared.isIdleTimerDisabled = true
_headerView.xionLogoView.beginAnimating()
if _serverMultiplex.devices.count == 0 {
// Do initial refresh
_updateConnectivityStatus(.connecting)
_serverMultiplex.refreshDevices()
_startUpdatingDevices()
}
_isViewVisible = true
_updateActiveState()
}
override func viewDidDisappear(_ animated: Bool)
{
super.viewDidDisappear(animated)
UIApplication.shared.isIdleTimerDisabled = false
_isViewVisible = false
_updateActiveState()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator)
@@ -155,7 +165,21 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
_updateVisualization(true)
for device in devices {
_serverMultiplex.toggleDeviceState(device, state: device.state, completion: { (error: Error?) -> Void in })
_serverMultiplex.toggleDeviceState(
device,
state: device.state
) { [weak self] error in
guard let error else { return }
DispatchQueue.main.async {
guard let self else { return }
self._updateConnectivityStatus(.error)
self._serverMultiplex.refreshDevices()
var stderr = StandardErrorOutputStream()
print(error.localizedDescription, to: &stderr)
}
}
}
}
@@ -200,22 +224,41 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
}
}
internal func _startUpdatingDevices()
internal func applicationDidBecomeActive()
{
_updateDevices = true
let interval = DispatchTime.now() + Double(Int64(10 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC)
DispatchQueue.main.asyncAfter(deadline: interval) { () -> Void in
if (self._updateDevices) {
self._serverMultiplex.refreshDevices()
self._startUpdatingDevices()
}
}
_isApplicationActive = true
_updateActiveState()
}
internal func _stopUpdatingDevices()
internal func applicationWillResignActive()
{
_updateDevices = false
_isApplicationActive = false
_updateActiveState()
}
private func _updateActiveState()
{
let shouldBeActive = _isApplicationActive && _isViewVisible
if shouldBeActive, _refreshTimer == nil {
UIApplication.shared.isIdleTimerDisabled = true
_headerView.xionLogoView.beginAnimating()
_updateConnectivityStatus(.connecting)
_serverMultiplex.refreshDevices()
let timer = Timer(timeInterval: 10, repeats: true) { [weak self] _ in
self?._serverMultiplex.refreshDevices()
}
RunLoop.main.add(timer, forMode: .common)
_refreshTimer = timer
} else if !shouldBeActive, _refreshTimer != nil {
_refreshTimer?.invalidate()
_refreshTimer = nil
_serverMultiplex.disconnect()
_updateConnectivityStatus(.disconnected)
_headerView.xionLogoView.stopAnimating()
UIApplication.shared.isIdleTimerDisabled = false
}
}
// MARK: Server Multiplex Delegate
@@ -232,6 +275,7 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
{
_switchesController.devicesStateChanged(devices.switches)
_lightsController.devicesStateChanged(devices.lights)
_updateVisualization(true)
}
func serverMultiplex(_ multiplex: ServerMultiplex, didReceiveAcknowledgementFromServer server: Server)
@@ -240,6 +284,11 @@ class MainViewController: UIViewController, SwitchesViewControllerDelegate, Serv
let groupConnectionStatus = multiplex.groupConnectionStatus()
_updateConnectivityStatus(groupConnectionStatus)
}
func serverMultiplexConnectionStatusDidChange(_ multiplex: ServerMultiplex)
{
_updateConnectivityStatus(multiplex.groupConnectionStatus())
}
func serverMultiplex(_ multiplex: ServerMultiplex, didEncounterError error: Error)
{
@@ -10,7 +10,7 @@ import Darwin
import Foundation
import UIKit
protocol SwitchesViewControllerDelegate: class
protocol SwitchesViewControllerDelegate: AnyObject
{
func switchesViewControllerDidToggleDevices(_ controller: SwitchesViewController, devices: [AnyDevice])
}
@@ -29,9 +29,8 @@ class SwitchesViewController: UIViewController,
fileprivate var _collectionView: UICollectionView = UICollectionView(frame: CGRect.zero,
collectionViewLayout: UICollectionViewFlowLayout())
fileprivate var _collectionViewDataSource: UICollectionViewDiffableDataSource<Int, Int>!
fileprivate var _currentDevicesHash: Int = 0
fileprivate var _collectionViewDataSource:
UICollectionViewDiffableDataSource<Int, ItemIdentifier>!
fileprivate let _label = UILabel(frame: .zero)
@@ -42,7 +41,7 @@ class SwitchesViewController: UIViewController,
static fileprivate let actionCellsSectionIdentifier = 0
static fileprivate let switchCellsSectionIdentifier = 1
fileprivate enum ActionCell: Int, CaseIterable
fileprivate enum ActionCell: Int, CaseIterable, Hashable
{
case allOn
case allOff
@@ -59,6 +58,12 @@ class SwitchesViewController: UIViewController,
static let count: Int = { return ActionCell.allCases.count }()
}
fileprivate enum ItemIdentifier: Hashable
{
case action(ActionCell)
case device(String)
}
public enum ActionCellLayout
{
@@ -85,8 +90,8 @@ class SwitchesViewController: UIViewController,
// Action cells
snapshot.appendItems([
SwitchesViewController.ActionCell.allOn.rawValue,
SwitchesViewController.ActionCell.allOff.rawValue
.action(.allOn),
.action(.allOff)
], toSection: SwitchesViewController.actionCellsSectionIdentifier)
_collectionViewDataSource.apply(snapshot, animatingDifferences: false)
@@ -158,7 +163,10 @@ class SwitchesViewController: UIViewController,
var snapshot = _collectionViewDataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: SwitchesViewController.switchCellsSectionIdentifier))
snapshot.appendItems(self.devices.map { $0.hashValue }, toSection: SwitchesViewController.switchCellsSectionIdentifier)
snapshot.appendItems(
self.devices.map { .device($0.serial) },
toSection: SwitchesViewController.switchCellsSectionIdentifier
)
snapshot.reloadSections([Self.actionCellsSectionIdentifier])
_collectionViewDataSource.apply(snapshot, animatingDifferences: false)
}
@@ -168,9 +176,14 @@ class SwitchesViewController: UIViewController,
{
var snapshot = _collectionViewDataSource.snapshot()
changedDevices.forEach { changedDevice in
if let existingDevice = (self.devices.first { $0.hashValue == changedDevice.hashValue }) {
if let existingDevice = self.devices.first(where: {
$0.serial == changedDevice.serial
}) {
existingDevice.state = changedDevice.state
snapshot.reloadItems([ existingDevice.hashValue ])
let identifier = ItemIdentifier.device(existingDevice.serial)
if snapshot.indexOfItem(identifier) != nil {
snapshot.reloadItems([identifier])
}
}
}
@@ -179,29 +192,36 @@ class SwitchesViewController: UIViewController,
// MARK: UICollectionView
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath, identifier: Int) -> UICollectionViewCell?
fileprivate func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath,
identifier: ItemIdentifier
) -> UICollectionViewCell?
{
if (indexPath.section == SwitchesViewController.actionCellsSectionIdentifier) {
switch identifier {
case .action(let action):
let reuseID = SwitchesViewController.collectionViewActionCellReuseIdentifier
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseID, for: indexPath) as! WemoActionCellView
cell.textLabel.text = ActionCell(rawValue: identifier)?.name().uppercased()
cell.textLabel.text = action.name().uppercased()
cell.accessibilityIdentifier = "action.\(action.rawValue)"
cell.accessibilityLabel = action.name()
cell.enabled = (self.devices.count > 0)
return cell
} else if (indexPath.section == SwitchesViewController.switchCellsSectionIdentifier) {
case .device(let serial):
let reuseID = SwitchesViewController.collectionViewDeviceSwitchCellReuseIdentifier
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseID, for: indexPath) as! WemoDeviceCellView
if let device = (self.devices.first { $0.hashValue == identifier }) {
if let device = self.devices.first(where: { $0.serial == serial }) {
cell.deviceName = device.name
cell.toggled = (device.state == .on)
cell.ordinal = indexPath.row
cell.accessibilityIdentifier = "device.\(device.serial)"
}
return cell
}
return nil
}
func collectionView(_ collectionView: UICollectionView,
@@ -252,9 +272,12 @@ class SwitchesViewController: UIViewController,
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
{
if (indexPath.section == SwitchesViewController.actionCellsSectionIdentifier) {
let tappedActionCell = ActionCell(rawValue: indexPath.item)
guard let identifier = _collectionViewDataSource.itemIdentifier(for: indexPath) else {
return
}
switch identifier {
case .action(let tappedActionCell):
for cell in collectionView.visibleCells {
if collectionView.indexPath(for: cell)?.section == SwitchesViewController.switchCellsSectionIdentifier {
let switchCell = cell as! WemoDeviceCellView
@@ -272,13 +295,17 @@ class SwitchesViewController: UIViewController,
}
self.delegate?.switchesViewControllerDidToggleDevices(self, devices: self.devices)
} else if (indexPath.section == SwitchesViewController.switchCellsSectionIdentifier) {
let cell = collectionView.cellForItem(at: indexPath) as! WemoDeviceCellView
case .device(let serial):
guard
let cell = collectionView.cellForItem(at: indexPath) as? WemoDeviceCellView,
let device = self.devices.first(where: { $0.serial == serial })
else {
return
}
cell.toggled = !cell.toggled
let device = self.devices[indexPath.row]
device.state = (cell.toggled ? .on : .off)
self.delegate?.switchesViewControllerDidToggleDevices(self, devices: [device])
}
}
@@ -11,7 +11,6 @@ import Foundation
import GLKit
import SceneKit
import UIKit
import SceneKit
let π = CGFloat(Double.pi)
@@ -39,7 +38,7 @@ class VisualizationViewController: UIViewController, SCNSceneRendererDelegate
override func loadView()
{
let opts = [SCNView.Option.preferredRenderingAPI.rawValue : SCNRenderingAPI.openGLES2.rawValue]
let view = SCNView(frame: UIScreen.main.bounds, options: opts)
let view = SCNView(frame: .zero, options: opts)
view.backgroundColor = UIColor.black
view.scene = _scene
view.allowsCameraControl = false