├── .gitignore ├── .swift-version ├── .swiftformat ├── .swiftlint ├── BuildTools └── SwiftFormat │ └── swiftformat ├── Github-Info.xcconfig ├── Images ├── CCTV.PNG ├── CCTV.jpg ├── CCTV2.PNG ├── Car.PNG ├── Car2.PNG ├── Heater.PNG ├── Heater2.PNG ├── Heater3.PNG ├── Home.PNG ├── Home2.PNG ├── Icon.png ├── Lights.PNG ├── Vacuum.PNG ├── Vacuum2.PNG └── Widget.jpg ├── IntelliNest-github.xctestplan ├── IntelliNest.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── IntelliNest-github.xcscheme │ ├── IntelliNest-release.xcscheme │ └── IntelliNest.xcscheme ├── IntelliNest ├── AppDelegate.swift ├── AppMain.swift ├── Errors │ └── EntityError.swift ├── IntelliNest.xctestplan ├── Model │ ├── CameraEntity.swift │ ├── Coordinates.swift │ ├── DoorState.swift │ ├── Entity.swift │ ├── EntityProtocol.swift │ ├── HeaterEntity.swift │ ├── HomeLocationEntity.swift │ ├── InputNumberEntity.swift │ ├── LightEntity.swift │ ├── LockEntity.swift │ ├── LockState.swift │ ├── Lockable.swift │ ├── NordPoolEntity.swift │ ├── PurifierEntity.swift │ ├── PurifierSpeed.swift │ ├── QuickActionService.swift │ ├── RoborockEntity.swift │ ├── RoborockImageEntity.swift │ ├── SlideableProtocol.swift │ ├── SonnenEntity.swift │ ├── SonnenStatusEntity.swift │ ├── SwitchEntity.swift │ ├── UserManager.swift │ ├── Websocket │ │ ├── CallScriptRequest.swift │ │ ├── CallServiceRequest.swift │ │ ├── ServiceData.swift │ │ ├── SubscribeRequest.swift │ │ └── UpdateEntityRequest.swift │ ├── YaleLock.swift │ └── YaleLockResponse.swift ├── Navigation │ ├── Destination.swift │ ├── Navigator.swift │ ├── NavigatorHelpers.swif │ ├── NavigatorHelpers.swift │ ├── ToolbarBackButton.swift │ └── ToolbarItems.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Services │ ├── GeofenceManager.swift │ ├── NetworkTask.swift │ ├── NotificationService.swift │ ├── PreviewProvideUtil.swift │ ├── RestAPIService.swift │ ├── URLCreator.swift │ ├── URLRequestBuilder.swift │ └── YaleApiService.swift ├── Supporting Files │ ├── AppIntentVocabulary.plist │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── App Store.png │ │ │ ├── Contents.json │ │ │ ├── iPad App Icon iOS 7,12 1.png │ │ │ ├── iPad App Icon iOS 7,12@2x.png │ │ │ ├── iPad Pro Icon iOS 9,12@2x.png │ │ │ ├── iPad Settings 1.png │ │ │ ├── iPad Settings.png │ │ │ ├── iPad Settings@2x 1.png │ │ │ ├── iPad Settings@2x.png │ │ │ ├── iPad Spotlight iOS 7,12.png │ │ │ ├── iPad Spotlight iOS 7,12@2x 1.png │ │ │ ├── iPad Spotlight iOS 7,12@2x.png │ │ │ ├── iPad notification iOS 7,12.png │ │ │ ├── iPad notification iOS 7,12@2x.png │ │ │ ├── iPhone App Icon iOS 5,6.png │ │ │ ├── iPhone App Icon iOS 5,6@2x.png │ │ │ ├── iPhone App Icon iOS 7,12@2x.png │ │ │ ├── iPhone App Icon iOS 7,12@3x.png │ │ │ ├── iPhone Spotlight iOS 5,6 & Settings@3x.png │ │ │ ├── iPhone Spotlight iOS 7,12@3x.png │ │ │ ├── iPhone notification iOS 7,12@2x.png │ │ │ └── iPhone notification iOS 7,12@3x.png │ │ ├── Contents.json │ │ ├── Images.imageset │ │ │ └── Contents.json │ │ ├── aircondition.imageset │ │ │ ├── Contents.json │ │ │ └── heater.png │ │ ├── defrost.filled.imageset │ │ │ ├── Contents.json │ │ │ └── defrost.filled.png │ │ ├── ev-plug-ccs2.imageset │ │ │ ├── Contents.json │ │ │ └── ev-plug-ccs2.png │ │ ├── ev-plug-type2.imageset │ │ │ ├── Contents.json │ │ │ └── ev-plug-type2.png │ │ ├── floorplan.imageset │ │ │ ├── Contents.json │ │ │ └── floorplan.png │ │ ├── gym.imageset │ │ │ ├── Contents.json │ │ │ └── gym.png │ │ ├── hallway.imageset │ │ │ ├── Contents.json │ │ │ └── hallway.png │ │ ├── powergrid.imageset │ │ │ ├── Contents.json │ │ │ └── powergrid.png │ │ ├── refresh.imageset │ │ │ ├── Contents.json │ │ │ └── icons8-iOS Glyph-tovbiOioOGAO-50-ffffff.png │ │ ├── roborocks7.imageset │ │ │ ├── Contents.json │ │ │ └── icon_128x128.png │ │ ├── seatheater.filled.imageset │ │ │ ├── Contents.json │ │ │ └── seatheater.filled.png │ │ ├── settings.imageset │ │ │ ├── Contents.json │ │ │ └── settings.png │ │ ├── solarpanel.imageset │ │ │ ├── Contents.json │ │ │ └── solarpanel.png │ │ ├── vince.imageset │ │ │ ├── Contents.json │ │ │ └── vince.png │ │ └── washing.imageset │ │ │ ├── Contents.json │ │ │ └── washing.png │ ├── Info.plist │ └── IntelliNest.entitlements ├── Utils │ ├── Constants │ │ ├── Actions.swift │ │ ├── Closures.swift │ │ ├── DeviceID.swift │ │ ├── Domain.swift │ │ ├── EntityId.swift │ │ ├── FullScreenBackgroundOverlay.swift │ │ ├── GlobalConstants.swift │ │ ├── HTTPMethod.swift │ │ ├── Heater.swift │ │ ├── JSONKey.swift │ │ ├── LockID.swift │ │ ├── ScriptID.swift │ │ ├── ScriptVariableKeys.swift │ │ ├── ServiceDataKeys.swift │ │ ├── ServiceID.swift │ │ ├── ServiceTargetKeys.swift │ │ ├── ServiceValues.swift │ │ └── StorageKeys.swift │ ├── Extensions │ │ ├── CalendarExtension.swift │ │ ├── CollectionExtension.swift │ │ ├── ColorExtension.swift │ │ ├── DateExtension.swift │ │ ├── DoubleExtension.swift │ │ ├── EncodableExtension.swift │ │ ├── FontExtension.swift │ │ ├── HomeViewModelExtension.swift │ │ ├── ImageExtension.swift │ │ ├── LinearGradientExtension.swift │ │ ├── LogExtension.swift │ │ ├── StringExtension.swift │ │ ├── TaskExtension.swift │ │ ├── UINavigationController.swift │ │ ├── UserDefaultsExtension.swift │ │ ├── UserDefaultsExtensionShared.swift │ │ ├── ViewExtension.swift │ │ └── ViewModels │ │ │ └── HomeViewModelExtension+LockServiceProtocol.swift │ └── SSIDUtil.swift ├── ViewModels │ ├── ElectricityViewModel.swift │ ├── HeatersViewModel.swift │ ├── HeatersViewModelExtension.swift │ ├── HomeViewModel.swift │ ├── LightsViewModel.swift │ ├── LockServiceProtocol.swift │ ├── LynkViewModel.swift │ ├── LynkViewModelHelpers.swift │ ├── RoborockViewModel.swift │ └── VLCMediaplayerMock.swift └── Views │ ├── Components │ ├── BatteryView.swift │ ├── DashboardButtonView.swift │ ├── ErrorBannerView.swift │ ├── FuelView.swift │ ├── INText.swift │ ├── NavigationButtonView.swift │ ├── NumberPickerScrollView.swift │ ├── PrimaryContentBorderView.swift │ ├── ServiceButtonView.swift │ ├── VerticalDivider.swift │ └── ZoomableImageView.swift │ ├── Dashboards │ ├── ElectricityView.swift │ ├── HeatersView.swift │ ├── HomeView.swift │ ├── LeafView.swift │ ├── LightsView.swift │ ├── LynkView.swift │ └── RoborockView.swift │ ├── Electricity │ ├── ElectricityFlowView.swift │ ├── FlowIndicatorView.swift │ ├── NordPoolHistoryView.swift │ └── SonnenSettingsView.swift │ ├── Heater │ ├── DetailedHeaterView.swift │ ├── FanModeButtonView.swift │ ├── FanModeView.swift │ ├── HorizontalButtonView.swift │ ├── HorizontalModeView.swift │ ├── HvacModeView.swift │ ├── NumberTextView.swift │ ├── PurifierView.swift │ ├── SimpleHeaterView.swift │ ├── ThermometerGroupView.swift │ ├── ThermometerView.swift │ ├── VerticalButtonView.swift │ └── VerticalPositionView.swift │ ├── Home │ └── CoffeeMachineSchedulingView.swift │ ├── Labels │ └── HeaterGroupLabels.swift │ ├── Light │ ├── BulbButton.swift │ ├── DualBulbRoomView.swift │ ├── SingleRoomLight.swift │ └── VerticalSlider.swift │ ├── Lynk │ ├── DateTimePickerView.swift │ ├── LimitPickerView.swift │ └── LynkHeaterOptionsView.swift │ └── Roborock │ ├── RoborockInfoView.swift │ ├── RoborockMainButtons.swift │ ├── RoborockMapImageView.swift │ ├── RoborockRoomsView.swift │ └── SinceEmptiedView.swift ├── IntelliNestTests ├── EntityTest.swift ├── InputNumberEntityTest.swift ├── LightEntityTests.swift ├── LockEntityTest.swift └── URLProtocolStub.swift ├── IntelliWidget ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ ├── WidgetBackground.colorset │ │ └── Contents.json │ └── widget-home-icon.imageset │ │ ├── Contents.json │ │ └── widget-home-icon.png ├── CarHeaterWidget.swift ├── HomeWidget.swift ├── Info.plist ├── IntelliWidgetBundle.swift └── IntelliWidgetExtension.entitlements ├── LICENSE ├── README.md ├── Scripts └── generate_code_stats.zsh └── buildServer.json /.gitignore: -------------------------------------------------------------------------------- 1 | IntelliNest.xcodeproj/project.xcworkspace/xcuserdata/* 2 | IntelliNest.xcodeproj/xcuserdata/tobias.xcuserdatad/xcschemes/xcschememanagement.plist 3 | .xcbkptlist 4 | xcdebugger 5 | .DS_Store 6 | IntelliNest-Info.xcconfig 7 | # CocoaPods 8 | Pods/ 9 | .nvim/ 10 | buildserver.json 11 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.10 2 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --exclude Carthage,.build,vendor 2 | --commas inline 3 | --disable specifiers, wrapMultilineStatementBraces, fileHeader, andOperator, unusedArguments 4 | --header strip 5 | -------------------------------------------------------------------------------- /.swiftlint: -------------------------------------------------------------------------------- 1 | line_length: 2 | warning: 140 3 | error: 160 4 | disabled_rules: 5 | - blanket_disable_command 6 | - nesting 7 | excluded: # paths to ignore during linting. Takes precedence over `included`. 8 | - Pods 9 | - R.generated.swift 10 | - .build # Where Swift Package Manager checks out dependency sources 11 | -------------------------------------------------------------------------------- /BuildTools/SwiftFormat/swiftformat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/BuildTools/SwiftFormat/swiftformat -------------------------------------------------------------------------------- /Github-Info.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // Github-Info.xcconfig 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-05-22. 6 | // 7 | 8 | // Configuration settings file format documentation can be found at: 9 | // https://help.apple.com/xcode/#/dev745c5c974 10 | SECRET_HASS_TOKEN = default_token 11 | SECRET_HASS_TOKEN_SARAH = default_token_sarah 12 | EXTERNAL_URL = 192.218.223.123/ 13 | LOCAL_SSID = None 14 | SECRET_SHIP_BOOK_APP_ID = appID 15 | SECRET_SHIP_BOOK_APP_KEY = appKey 16 | SECRET_YALE_API_KEY = abc 17 | SECRET_YALE_API_URL = abc.com 18 | SECRET_RTSP_STREAM_CAMERA_BACK = 192.168.5.12 19 | SECRET_RTSP_STREAM_CAMERA_FRONT = 192.168.5.12 20 | SECRET_RTSP_STREAM_CAMERA_CARPORT = 192.168.5.12 21 | SECRET_RTSP_STREAM_CAMERA_VINCE = 192.168.5.12 22 | -------------------------------------------------------------------------------- /Images/CCTV.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/CCTV.PNG -------------------------------------------------------------------------------- /Images/CCTV.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/CCTV.jpg -------------------------------------------------------------------------------- /Images/CCTV2.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/CCTV2.PNG -------------------------------------------------------------------------------- /Images/Car.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/Car.PNG -------------------------------------------------------------------------------- /Images/Car2.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/Car2.PNG -------------------------------------------------------------------------------- /Images/Heater.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/Heater.PNG -------------------------------------------------------------------------------- /Images/Heater2.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/Heater2.PNG -------------------------------------------------------------------------------- /Images/Heater3.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/Heater3.PNG -------------------------------------------------------------------------------- /Images/Home.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/Home.PNG -------------------------------------------------------------------------------- /Images/Home2.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/Home2.PNG -------------------------------------------------------------------------------- /Images/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/Icon.png -------------------------------------------------------------------------------- /Images/Lights.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/Lights.PNG -------------------------------------------------------------------------------- /Images/Vacuum.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/Vacuum.PNG -------------------------------------------------------------------------------- /Images/Vacuum2.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/Vacuum2.PNG -------------------------------------------------------------------------------- /Images/Widget.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/Images/Widget.jpg -------------------------------------------------------------------------------- /IntelliNest-github.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "CEB6A170-3D07-4808-9525-DC1115BD03E5", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "codeCoverage" : false, 13 | "targetForVariableExpansion" : { 14 | "containerPath" : "container:IntelliNest.xcodeproj", 15 | "identifier" : "F67023F627A177320070FFBB", 16 | "name" : "IntelliNest" 17 | } 18 | }, 19 | "testTargets" : [ 20 | { 21 | "parallelizable" : true, 22 | "target" : { 23 | "containerPath" : "container:IntelliNest.xcodeproj", 24 | "identifier" : "F670240627A177340070FFBB", 25 | "name" : "IntelliNestTests" 26 | } 27 | } 28 | ], 29 | "version" : 1 30 | } 31 | -------------------------------------------------------------------------------- /IntelliNest.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /IntelliNest.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /IntelliNest.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "66d0e924887faeee4da8d3368133bc4c63f99389594ed32f1ce043232531e4e8", 3 | "pins" : [ 4 | { 5 | "identity" : "shipbooksdk-ios", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/ShipBook/ShipBookSDK-iOS.git", 8 | "state" : { 9 | "revision" : "20864a7f61064c541d73b5adaa781e5029e56bc7", 10 | "version" : "1.1.20" 11 | } 12 | }, 13 | { 14 | "identity" : "starscream", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/daltoniam/Starscream", 17 | "state" : { 18 | "revision" : "c6bfd1af48efcc9a9ad203665db12375ba6b145a", 19 | "version" : "4.0.8" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /IntelliNest/Errors/EntityError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EntityError.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-07-07. 6 | // 7 | 8 | import Foundation 9 | 10 | enum EntityError: Error { 11 | case updateTooEarly 12 | case badRequest 13 | case badResponse 14 | case badImageData 15 | case httpRequestFailure 16 | case genericError 17 | } 18 | -------------------------------------------------------------------------------- /IntelliNest/IntelliNest.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "1694E45D-B5AE-4849-B0E6-FAA62C5C8D8A", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "codeCoverage" : { 13 | "targets" : [ 14 | { 15 | "containerPath" : "container:IntelliNest.xcodeproj", 16 | "identifier" : "F67023F627A177320070FFBB", 17 | "name" : "IntelliNest" 18 | } 19 | ] 20 | }, 21 | "environmentVariableEntries" : [ 22 | { 23 | "key" : "CFNETWORK_DIAGNOSTICS", 24 | "value" : "0" 25 | } 26 | ], 27 | "targetForVariableExpansion" : { 28 | "containerPath" : "container:IntelliNest.xcodeproj", 29 | "identifier" : "F67023F627A177320070FFBB", 30 | "name" : "IntelliNest" 31 | } 32 | }, 33 | "testTargets" : [ 34 | { 35 | "parallelizable" : true, 36 | "target" : { 37 | "containerPath" : "container:IntelliNest.xcodeproj", 38 | "identifier" : "F670240627A177340070FFBB", 39 | "name" : "IntelliNestTests" 40 | } 41 | } 42 | ], 43 | "version" : 1 44 | } 45 | -------------------------------------------------------------------------------- /IntelliNest/Model/CameraEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CameraEntity.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-06-18. 6 | // 7 | 8 | import Foundation 9 | 10 | struct CameraEntity: EntityProtocol { 11 | var entityId: EntityId 12 | var state: String 13 | var nextUpdate = Date().addingTimeInterval(-1) 14 | var isActive: Bool = false 15 | var isLoading: Bool = false 16 | // var imageUrlString: String = "" 17 | var urlPath = "" 18 | 19 | enum CodingKeys: String, CodingKey { 20 | case entityId = "entity_id" 21 | case state 22 | case attributes 23 | } 24 | 25 | init(entityId: EntityId, state: String = "Loading") { 26 | self.entityId = entityId 27 | self.state = state 28 | } 29 | 30 | init(from decoder: Decoder) throws { 31 | let container = try decoder.container(keyedBy: CodingKeys.self) 32 | let entityId = try EntityId(rawValue: container.decode(String.self, forKey: .entityId)) 33 | self.entityId = entityId ?? EntityId.unknown 34 | state = try container.decode(String.self, forKey: .state) 35 | 36 | let attributes = try container.decode(CameraAttributes.self, forKey: .attributes) 37 | urlPath = attributes.urlPath 38 | } 39 | 40 | mutating func setNextUpdateTime() { 41 | nextUpdate = Date().addingTimeInterval(-1) 42 | } 43 | 44 | mutating func updateIsActive() { 45 | isActive.toggle() 46 | } 47 | 48 | static func == (lhs: CameraEntity, rhs: CameraEntity) -> Bool { 49 | false 50 | } 51 | } 52 | 53 | struct CameraAttributes: Decodable { 54 | var urlPath: String 55 | 56 | enum CodingKeys: String, CodingKey { 57 | case urlPath = "entity_picture" 58 | } 59 | 60 | init(from decoder: Decoder) throws { 61 | let container = try decoder.container(keyedBy: CodingKeys.self) 62 | urlPath = try container.decode(String.self, forKey: .urlPath) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /IntelliNest/Model/Coordinates.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Coordinates.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2024-01-24. 6 | // 7 | 8 | import CoreLocation 9 | import Foundation 10 | 11 | struct Coordinates: Codable, Hashable { 12 | let longitude: Double 13 | let latitude: Double 14 | 15 | func toCLLocationCoordinate2D() -> CLLocationCoordinate2D { 16 | CLLocationCoordinate2D(latitude: latitude, longitude: longitude) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /IntelliNest/Model/DoorState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DoorState.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-05-18. 6 | // 7 | 8 | import Foundation 9 | 10 | enum DoorState: String, Decodable { 11 | case open 12 | case closed 13 | case unknown 14 | } 15 | -------------------------------------------------------------------------------- /IntelliNest/Model/EntityProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EntityProtocol.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-08-08. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol EntityProtocol: Decodable, Equatable { 11 | var entityId: EntityId { get } 12 | var state: String { get set } 13 | var nextUpdate: Date { get set } 14 | var isActive: Bool { get } 15 | 16 | mutating func setNextUpdateTime() 17 | func canUpdate() -> Bool 18 | } 19 | 20 | extension EntityProtocol { 21 | func canUpdate() -> Bool { 22 | if nextUpdate.timeIntervalSinceNow < 0 { 23 | true 24 | } else { 25 | false 26 | } 27 | } 28 | 29 | mutating func setNextUpdateTime() { 30 | nextUpdate = Date().addingTimeInterval(0.5) 31 | } 32 | } 33 | 34 | struct EntityMinimized: Decodable { 35 | let state: String 36 | 37 | enum CodingKeys: String, CodingKey { 38 | case state 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /IntelliNest/Model/HomeLocationEntity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct HomeLocationEntity: EntityProtocol { 4 | let entityId: EntityId 5 | var state: String 6 | var latitude: Double 7 | var longitude: Double 8 | var nextUpdate = Date.now 9 | var isActive = false 10 | 11 | enum CodingKeys: String, CodingKey { 12 | case entityId = "entity_id" 13 | case state 14 | case attributes 15 | } 16 | 17 | init(from decoder: Decoder) throws { 18 | let container = try decoder.container(keyedBy: CodingKeys.self) 19 | let entityId = try EntityId(rawValue: container.decode(String.self, forKey: .entityId)) 20 | self.entityId = entityId ?? EntityId.unknown 21 | state = try container.decode(String.self, forKey: .state) 22 | 23 | let attributes = try container.decode(HomeLocationAttributes.self, forKey: .attributes) 24 | latitude = attributes.latitude 25 | longitude = attributes.longitude 26 | } 27 | } 28 | 29 | struct HomeLocationAttributes: Decodable { 30 | let latitude: Double 31 | let longitude: Double 32 | } 33 | -------------------------------------------------------------------------------- /IntelliNest/Model/InputNumberEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InputNumberEntity.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-03-28. 6 | // 7 | 8 | import Foundation 9 | 10 | struct InputNumberEntity: EntityProtocol { 11 | var entityId: EntityId 12 | var state: String { didSet { 13 | inputNumber = Double(state) ?? 0 14 | }} 15 | var lastUpdated: Date 16 | var lastChanged: Date 17 | var nextUpdate = Date().addingTimeInterval(-1) 18 | var isActive = false 19 | var isLoading = false 20 | var inputNumber: Double = 0 21 | 22 | enum CodingKeys: String, CodingKey { 23 | case entityId = "entity_id" 24 | case state 25 | case lastChanged = "last_changed" 26 | case lastUpdated = "last_updated" 27 | } 28 | 29 | init(entityId: EntityId, state: String = "Loading") { 30 | self.entityId = entityId 31 | self.state = state 32 | self.lastChanged = .distantPast 33 | self.lastUpdated = .distantPast 34 | } 35 | 36 | static let numberFormat: NumberFormatter = { 37 | let numberFormatter = NumberFormatter() 38 | numberFormatter.minimumFractionDigits = 0 39 | numberFormatter.maximumFractionDigits = 1 40 | return numberFormatter 41 | }() 42 | 43 | init(from decoder: Decoder) throws { 44 | let container = try decoder.container(keyedBy: CodingKeys.self) 45 | let entityId = try EntityId(rawValue: container.decode(String.self, forKey: .entityId)) 46 | self.entityId = entityId ?? EntityId.unknown 47 | state = try container.decode(String.self, forKey: .state) 48 | inputNumber = try Double(container.decode(String.self, forKey: .state)) ?? 22 49 | 50 | let dateFormatter = DateFormatter() 51 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ" 52 | let lastChanged = try container.decode(String.self, forKey: .lastChanged) 53 | self.lastChanged = dateFormatter.date(from: lastChanged) ?? .distantPast 54 | let lastUpdated = try container.decode(String.self, forKey: .lastUpdated) 55 | self.lastUpdated = dateFormatter.date(from: lastUpdated) ?? .distantPast 56 | } 57 | 58 | mutating func updateIsActive() { 59 | isActive = false 60 | } 61 | 62 | mutating func setNextUpdateTime() { 63 | nextUpdate = Date().addingTimeInterval(0.29) 64 | } 65 | 66 | static func == (lhs: InputNumberEntity, rhs: InputNumberEntity) -> Bool { 67 | lhs.entityId == rhs.entityId && lhs.state == rhs.state 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /IntelliNest/Model/LightEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LightEntity.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-06-30. 6 | // 7 | 8 | import Foundation 9 | 10 | struct LightEntity: EntityProtocol, Decodable { 11 | var entityId: EntityId 12 | var state: String 13 | var nextUpdate = Date().addingTimeInterval(-1) 14 | var isActive: Bool { 15 | state == "on" ? true : false 16 | } 17 | 18 | var isSliding = false 19 | var isUpdating = false 20 | 21 | var brightness: Int 22 | var groupedLightIDs: [EntityId]? 23 | 24 | enum CodingKeys: String, CodingKey { 25 | case entityId = "entity_id" 26 | case state 27 | case attributes 28 | } 29 | 30 | init(entityId: EntityId, state: String = "Loading", groupedLightIDs: [EntityId]? = nil) { 31 | self.entityId = entityId 32 | self.state = state 33 | brightness = -1 34 | self.groupedLightIDs = groupedLightIDs 35 | } 36 | 37 | init(from decoder: Decoder) throws { 38 | let container = try decoder.container(keyedBy: CodingKeys.self) 39 | entityId = try container.decode(EntityId.self, forKey: .entityId) 40 | state = try container.decode(String.self, forKey: .state) 41 | 42 | if let attributesContainer = try? container.nestedContainer(keyedBy: AttributesCodingKeys.self, forKey: .attributes) { 43 | brightness = try attributesContainer.decodeIfPresent(Int.self, forKey: .brightness) ?? -1 44 | } else { 45 | brightness = -1 46 | } 47 | groupedLightIDs = nil 48 | } 49 | 50 | mutating func updateIsActive() {} 51 | 52 | private enum AttributesCodingKeys: String, CodingKey { 53 | case brightness 54 | } 55 | 56 | private struct Attributes: Decodable { 57 | var brightness: Int 58 | 59 | init(from decoder: Decoder) throws { 60 | let data = try decoder.container(keyedBy: AttributesCodingKeys.self) 61 | brightness = try data.decodeIfPresent(Int.self, forKey: .brightness) ?? -1 62 | } 63 | } 64 | 65 | mutating func setNextUpdateTime() { 66 | nextUpdate = Date().addingTimeInterval(0.5) 67 | } 68 | 69 | static func == (lhs: LightEntity, rhs: LightEntity) -> Bool { 70 | lhs.entityId == rhs.entityId && 71 | lhs.brightness == rhs.brightness && 72 | lhs.isActive == rhs.isActive && 73 | lhs.state == rhs.state 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /IntelliNest/Model/LockEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LockEntity.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-04-19. 6 | // 7 | 8 | import Foundation 9 | import ShipBookSDK 10 | 11 | struct LockEntity: Lockable, EntityProtocol { 12 | var entityId: EntityId 13 | var id: LockID { 14 | switch entityId { 15 | case .storageLock: 16 | .storageDoor 17 | case .lynkDoorLock: 18 | .lynkDoor 19 | default: 20 | .storageDoor 21 | } 22 | } 23 | 24 | var state: String { didSet { 25 | lockState = LockState(rawValue: state) ?? .unknown 26 | if lockState == expectedState || expectedStateIsOld { 27 | expectedState = .unknown 28 | } 29 | }} 30 | var nextUpdate = Date().addingTimeInterval(-1) 31 | var expectedStateSetDate: Date? 32 | 33 | var lockState: LockState = .unknown 34 | var expectedState: LockState = .unknown { 35 | didSet { 36 | self.expectedStateSetDate = Date() 37 | } 38 | } 39 | 40 | enum CodingKeys: String, CodingKey { 41 | case entityId = "entity_id" 42 | case state 43 | } 44 | 45 | init(entityId: EntityId, state: String = "Loading") { 46 | self.entityId = entityId 47 | self.state = state 48 | } 49 | 50 | init(from decoder: Decoder) throws { 51 | let container = try decoder.container(keyedBy: CodingKeys.self) 52 | let entityId = try EntityId(rawValue: container.decode(String.self, forKey: .entityId)) 53 | self.entityId = entityId ?? EntityId.unknown 54 | state = try container.decode(String.self, forKey: .state) 55 | lockState = LockState(rawValue: state) ?? .unknown 56 | } 57 | 58 | static func == (lhs: LockEntity, rhs: LockEntity) -> Bool { 59 | lhs.isActive == rhs.isActive && 60 | lhs.entityId == rhs.entityId && 61 | lhs.state == rhs.state 62 | } 63 | 64 | func shouldReload() -> Bool { 65 | isActive || isLoading 66 | } 67 | 68 | mutating func setNextUpdateTime() { 69 | nextUpdate = Date().addingTimeInterval(0.29) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /IntelliNest/Model/LockState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LockState.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-05-18. 6 | // 7 | 8 | import Foundation 9 | 10 | enum LockState: String, Decodable { 11 | case locked 12 | case locking 13 | case unlocked 14 | case unlocking 15 | case unknown 16 | } 17 | -------------------------------------------------------------------------------- /IntelliNest/Model/Lockable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Lockable.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-05-18. 6 | // 7 | 8 | import SwiftUI 9 | 10 | protocol Lockable { 11 | var id: LockID { get } 12 | var lockState: LockState { get set } 13 | var isLoading: Bool { get } 14 | var actionText: String { get } 15 | var expectedState: LockState { get set } 16 | var expectedStateSetDate: Date? { get set } 17 | var expectedStateIsOld: Bool { get } 18 | var isActive: Bool { get } 19 | var image: Image { get } 20 | func stateToString() -> String 21 | } 22 | 23 | extension Lockable { 24 | var actionText: String { 25 | switch lockState { 26 | case .unlocked: 27 | "Lås" 28 | case .unlocking: 29 | "Låser upp" 30 | case .locking: 31 | "Låser" 32 | case .locked: 33 | "Lås upp" 34 | default: 35 | "Lås upp" 36 | } 37 | } 38 | 39 | var isLoading: Bool { 40 | (expectedState != .unknown && lockState != expectedState) || (lockState == .unknown && !expectedStateIsOld) 41 | } 42 | 43 | var isActive: Bool { 44 | [.unlocked, .unlocking, .locking].contains(lockState) 45 | } 46 | 47 | var expectedStateIsOld: Bool { 48 | if let setDate = expectedStateSetDate { 49 | Date().timeIntervalSince(setDate) > 30 50 | } else { 51 | false 52 | } 53 | } 54 | 55 | var image: Image { 56 | if lockState == .locked || lockState == .unlocking { 57 | Image(systemImageName: .locked) 58 | } else if lockState == .unlocked || lockState == .locking { 59 | Image(systemImageName: .unlocked) 60 | } else { 61 | Image(systemImageName: .lockSlash) 62 | } 63 | } 64 | 65 | func stateToString() -> String { 66 | switch lockState { 67 | case .locked: 68 | "låst" 69 | case .unlocked: 70 | "olåst" 71 | case .unlocking: 72 | "låser upp" 73 | case .locking: 74 | "låser" 75 | default: 76 | lockState.rawValue 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /IntelliNest/Model/PurifierEntity.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum PurifierFanMode: String, Decodable { 4 | case off 5 | case manual 6 | case auto 7 | } 8 | 9 | struct PurifierEntity: Decodable { 10 | var fanMode: PurifierFanMode = .off 11 | var speed: Double = 0 12 | var temperature: Double = 0 13 | var humidity: Int = 0 14 | var isActive: Bool { 15 | fanMode != .off 16 | } 17 | 18 | init() {} 19 | } 20 | -------------------------------------------------------------------------------- /IntelliNest/Model/PurifierSpeed.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct PurifierSpeed: Decodable, EntityProtocol { 4 | var entityId = EntityId.purifierFanSpeed 5 | var state = "" 6 | var nextUpdate = Date.now 7 | var isActive = false 8 | var speed: Double 9 | 10 | enum CodingKeys: String, CodingKey { 11 | case entityId = "entity_id" 12 | case state 13 | case attributes 14 | } 15 | 16 | init(from decoder: Decoder) throws { 17 | let container = try decoder.container(keyedBy: CodingKeys.self) 18 | let entityId = try EntityId(rawValue: container.decode(String.self, forKey: .entityId)) 19 | self.entityId = entityId ?? EntityId.unknown 20 | state = try container.decode(String.self, forKey: .state) 21 | 22 | let attributes = try container.decode(PurifierSpeedAttributes.self, forKey: .attributes) 23 | speed = attributes.percentage.toFanSpeedTargetNumber 24 | } 25 | } 26 | 27 | struct PurifierSpeedAttributes: Decodable { 28 | let percentage: Double 29 | } 30 | -------------------------------------------------------------------------------- /IntelliNest/Model/QuickActionService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuickActionService.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-06-20. 6 | // 7 | 8 | import UIKit 9 | 10 | // 1 11 | enum QuickActionType: String { 12 | case carHeater 13 | } 14 | 15 | // 2 16 | enum QuickAction: Equatable { 17 | case carheater 18 | 19 | // 3 20 | init?(shortcutItem: UIApplicationShortcutItem) { 21 | // 4 22 | guard let type = QuickActionType(rawValue: shortcutItem.type) else { 23 | return nil 24 | } 25 | 26 | // 5 27 | switch type { 28 | case .carHeater: 29 | self = .carheater 30 | } 31 | } 32 | } 33 | 34 | // 6 35 | class QuickActionService: ObservableObject { 36 | @MainActor static let shared = QuickActionService() 37 | 38 | // 7 39 | @Published var action: QuickAction? 40 | } 41 | -------------------------------------------------------------------------------- /IntelliNest/Model/RoborockEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoborockEntity.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-04-25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RoborockEntity: EntityProtocol { 11 | let entityId: EntityId 12 | var state: String 13 | 14 | var nextUpdate = Date().addingTimeInterval(-1) 15 | var isActive: Bool { 16 | isCleaning || isReturning 17 | } 18 | 19 | var isCleaning: Bool { 20 | ["cleaning", "segment cleaning"].contains { $0 == state.lowercased() || $0 == status.lowercased() } 21 | } 22 | 23 | var isReturning: Bool { 24 | ["returning home", "returning"].contains { $0 == state.lowercased() || $0 == status.lowercased() } 25 | } 26 | 27 | var cleanIcon: Image { 28 | isCleaning ? .init(systemImageName: .pause) : .init(systemImageName: .play) 29 | } 30 | 31 | var cleanButtonTitle: String { 32 | isCleaning ? "Pausa" : "Dammsug" 33 | } 34 | 35 | var returningIcon: Image { 36 | isReturning ? .init(systemImageName: .pause) : .init(systemImageName: .house) 37 | } 38 | 39 | var returnButtonTitle: String { 40 | isReturning ? "Pausa" : "Docka" 41 | } 42 | 43 | var status: String = "" 44 | 45 | var batteryLevel: Int = -1 46 | var error: String = "" 47 | 48 | enum CodingKeys: String, CodingKey { 49 | case entityId = "entity_id" 50 | case state 51 | case attributes 52 | } 53 | 54 | init(entityId: EntityId, state: String = "Loading") { 55 | self.entityId = entityId 56 | self.state = state 57 | } 58 | 59 | init(from decoder: Decoder) throws { 60 | let container = try decoder.container(keyedBy: CodingKeys.self) 61 | let entityId = try EntityId(rawValue: container.decode(String.self, forKey: .entityId)) 62 | self.entityId = entityId ?? EntityId.unknown 63 | state = try container.decode(String.self, forKey: .state) 64 | 65 | let attributes = try container.decode(RoborockAttributes.self, forKey: .attributes) 66 | batteryLevel = attributes.batteryLevel 67 | } 68 | } 69 | 70 | private struct RoborockAttributes: Decodable { 71 | var batteryLevel: Int 72 | 73 | private enum CodingKeys: String, CodingKey { 74 | case batteryLevel = "battery_level" 75 | } 76 | 77 | init(from decoder: Decoder) throws { 78 | let container = try decoder.container(keyedBy: CodingKeys.self) 79 | batteryLevel = try container.decodeIfPresent(Int.self, forKey: .batteryLevel) ?? -1 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /IntelliNest/Model/RoborockImageEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoborockImageEntity.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-12-11. 6 | // 7 | 8 | import Foundation 9 | 10 | struct RoborockImageEntity: EntityProtocol { 11 | let entityId: EntityId 12 | var state = "" 13 | var urlPath = "" 14 | var nextUpdate = Date.now 15 | var isActive = false 16 | 17 | init(entityId: EntityId) { 18 | self.entityId = entityId 19 | } 20 | 21 | enum CodingKeys: String, CodingKey { 22 | case entityId = "entity_id" 23 | case state 24 | case attributes 25 | } 26 | 27 | init(from decoder: Decoder) throws { 28 | let container = try decoder.container(keyedBy: CodingKeys.self) 29 | let entityId = try EntityId(rawValue: container.decode(String.self, forKey: .entityId)) 30 | self.entityId = entityId ?? EntityId.unknown 31 | state = try container.decode(String.self, forKey: .state) 32 | 33 | let attributes = try container.decode(RoborockImageAttributes.self, forKey: .attributes) 34 | urlPath = attributes.entityPicture ?? "missingPath" 35 | } 36 | } 37 | 38 | struct RoborockImageAttributes: Decodable { 39 | let entityPicture: String? 40 | 41 | enum CodingKeys: String, CodingKey { 42 | case entityPicture = "entity_picture" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /IntelliNest/Model/SlideableProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SlideableProtocol.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-06-25. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | protocol Slideable { 12 | func value(isSliding: Bool) -> Int 13 | var isOn: Bool { get } 14 | var isUpdating: Bool { get set } 15 | } 16 | 17 | extension LightEntity: Slideable { 18 | func value(isSliding: Bool) -> Int { 19 | if isOn || isSliding || isUpdating { 20 | return brightness 21 | } 22 | 23 | return 0 24 | } 25 | 26 | var isOn: Bool { 27 | isActive 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /IntelliNest/Model/SonnenStatusEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SonnenStatusEntity.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-12-20. 6 | // 7 | 8 | import Foundation 9 | 10 | enum SonnenOperationModes: String, CaseIterable, Decodable { 11 | case manual = "1" 12 | case selfConsumption = "2" 13 | case timeOfUse = "10" 14 | case unknown 15 | 16 | var title: String { 17 | switch self { 18 | case .manual: 19 | "Manual" 20 | case .selfConsumption: 21 | "Self consumption" 22 | case .timeOfUse: 23 | "Time of use" 24 | case .unknown: 25 | "Unknown" 26 | } 27 | } 28 | } 29 | 30 | struct SonnenStatusEntity: EntityProtocol { 31 | let entityId: EntityId 32 | var gridPower: Double = 0.0 33 | var operationMode = SonnenOperationModes.unknown 34 | var hasFlowGridToBattery = false 35 | var hasFlowGridToHouse = false 36 | var hasFlowSolarToHouse = false 37 | var hasFlowBatteryToHouse = false 38 | var hasFlowSolarToBattery = false 39 | var hasFlowSolarToGrid = false 40 | var state: String 41 | var nextUpdate = Date.now 42 | var isActive = false 43 | 44 | enum CodingKeys: String, CodingKey { 45 | case entityId = "entity_id" 46 | case state 47 | case attributes 48 | } 49 | 50 | init(from decoder: Decoder) throws { 51 | let container = try decoder.container(keyedBy: CodingKeys.self) 52 | entityId = try EntityId(rawValue: container.decode(String.self, forKey: .entityId)) ?? .unknown 53 | state = try container.decode(String.self, forKey: .state) 54 | 55 | let attributes = try container.decode(SonnenStatusAttributes.self, forKey: .attributes) 56 | gridPower = attributes.gridPower 57 | operationMode = attributes.operationMode 58 | hasFlowGridToBattery = attributes.hasFlowGridToBattery 59 | hasFlowGridToHouse = attributes.hasFlowGridToHouse 60 | hasFlowSolarToHouse = attributes.hasFlowSolarToHouse 61 | hasFlowBatteryToHouse = attributes.hasFlowBatteryToHouse 62 | hasFlowSolarToBattery = attributes.hasFlowSolarToBattery 63 | hasFlowSolarToGrid = attributes.hasFlowSolarToGrid 64 | } 65 | } 66 | 67 | struct SonnenStatusAttributes: Decodable { 68 | let gridPower: Double 69 | let operationMode: SonnenOperationModes 70 | let hasFlowGridToBattery: Bool 71 | let hasFlowGridToHouse: Bool 72 | let hasFlowSolarToHouse: Bool 73 | let hasFlowBatteryToHouse: Bool 74 | let hasFlowSolarToBattery: Bool 75 | let hasFlowSolarToGrid: Bool 76 | 77 | enum CodingKeys: String, CodingKey { 78 | case gridPower = "GridFeedIn_W" 79 | case operationMode = "OperatingMode" 80 | case hasFlowGridToBattery = "FlowGridBattery" 81 | case hasFlowGridToHouse = "FlowConsumptionGrid" 82 | case hasFlowSolarToHouse = "FlowConsumptionProduction" 83 | case hasFlowBatteryToHouse = "FlowConsumptionBattery" 84 | case hasFlowSolarToBattery = "FlowProductionBattery" 85 | case hasFlowSolarToGrid = "FlowProductionGrid" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /IntelliNest/Model/SwitchEntity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwitchEntity.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-10-19. 6 | // 7 | 8 | import ShipBookSDK 9 | import SwiftUI 10 | 11 | struct SwitchEntity: EntityProtocol { 12 | var image: Image { 13 | isActive ? Image(systemImageName: .bolt) : Image(systemImageName: .boltSlash) 14 | } 15 | 16 | var activeColor: Color { 17 | guard isActive else { 18 | return .red 19 | } 20 | 21 | let now = Date() 22 | 23 | let elapsed = now.timeIntervalSince(lastChanged) / 60.0 24 | let minutesBeforeBlending = 1.0 25 | let minutesUntilPowered = 15.0 26 | if elapsed < minutesBeforeBlending { 27 | return .red 28 | } else if elapsed >= minutesUntilPowered { 29 | return .yellow 30 | } else { 31 | let ratio = (elapsed - minutesBeforeBlending) / (minutesUntilPowered - minutesBeforeBlending) 32 | return .blend(Color.red, with: Color.orange, ratio: CGFloat(ratio)) 33 | } 34 | } 35 | 36 | var nextUpdate = Date().addingTimeInterval(-1) 37 | var isActive: Bool { 38 | state.lowercased() == "on" 39 | } 40 | 41 | var title: String { 42 | switch entityId { 43 | case .coffeeMachine: 44 | "Kaffemaskinen" 45 | default: 46 | "" 47 | } 48 | } 49 | 50 | enum CodingKeys: String, CodingKey { 51 | case entityId = "entity_id" 52 | case state 53 | case lastChanged = "last_changed" 54 | } 55 | 56 | let entityId: EntityId 57 | var state: String 58 | var lastChanged: Date 59 | 60 | init(entityId: EntityId, state: String = "Loading", lastChanged: Date = .now) { 61 | self.entityId = entityId 62 | self.state = state 63 | self.lastChanged = lastChanged 64 | } 65 | 66 | init(from decoder: Decoder) throws { 67 | let container = try decoder.container(keyedBy: CodingKeys.self) 68 | let entityId = try EntityId(rawValue: container.decode(String.self, forKey: .entityId)) 69 | self.entityId = entityId ?? EntityId.unknown 70 | state = try container.decode(String.self, forKey: .state) 71 | 72 | let dateFormatter = ISO8601DateFormatter() 73 | if let lastChangedString = try? container.decode(String.self, forKey: .lastChanged), 74 | let date = dateFormatter.date(from: lastChangedString) { 75 | lastChanged = date 76 | } else { 77 | lastChanged = .distantFuture 78 | Log.error("Failed to parse last_changed in SwitchEntity") 79 | } 80 | } 81 | 82 | static func == (lhs: SwitchEntity, rhs: SwitchEntity) -> Bool { 83 | lhs.entityId == rhs.entityId 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /IntelliNest/Model/UserManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum User: String, CaseIterable { 4 | case sarah = "SL" 5 | case tobias = "TL" 6 | case guest = "Guest" 7 | case unknownUser = "Unknown User" 8 | 9 | var name: String { 10 | switch self { 11 | case .sarah: 12 | "Sarah" 13 | case .tobias: 14 | "Tobias" 15 | case .guest: 16 | "Gäst" 17 | case .unknownUser: 18 | "Unknown User" 19 | } 20 | } 21 | } 22 | 23 | struct UserManager { 24 | static let shared = UserManager() 25 | @MainActor 26 | static var currentUser: User { 27 | if let storedValue = UserDefaults.shared.string(forKey: StorageKeys.userInitials.rawValue), 28 | let user = User(rawValue: storedValue) { 29 | return user 30 | } 31 | 32 | return .unknownUser 33 | } 34 | 35 | @MainActor 36 | var isUserNotSet: Bool { 37 | UserDefaults.shared.string(forKey: StorageKeys.userInitials.rawValue) == nil 38 | } 39 | 40 | private init() {} 41 | 42 | func setUser(_ user: User) { 43 | Task { @MainActor in 44 | UserDefaults.shared.set(user.rawValue, forKey: StorageKeys.userInitials.rawValue) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /IntelliNest/Model/Websocket/CallScriptRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CallScriptRequest.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-07-11. 6 | // 7 | 8 | import Foundation 9 | 10 | struct CallScriptRequest: Encodable { 11 | let type = "call_service" 12 | let domain = Domain.script 13 | let service = Action.turnOn 14 | var serviceData: ServiceData = EmptyServiceData() 15 | let target: Target 16 | 17 | enum CodingKeys: String, CodingKey { 18 | case type 19 | case domain 20 | case service 21 | case serviceData = "service_data" 22 | case target 23 | } 24 | 25 | struct Target: Encodable { 26 | let entityIds: [String] 27 | 28 | enum CodingKeys: String, CodingKey { 29 | case entityIds = "entity_id" 30 | } 31 | } 32 | 33 | init(scriptID: ScriptID, variables: [ScriptVariableKeys: String]? = nil) { 34 | target = Target(entityIds: [scriptID.rawValue]) 35 | if let variables { 36 | serviceData = ScriptServiceData(variables: variables) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /IntelliNest/Model/Websocket/CallServiceRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ShipBookSDK 3 | 4 | struct CallServiceRequest: Encodable { 5 | let type: String 6 | let domain: String 7 | let service: String 8 | let target: [ServiceTargetKeys: ServiceValues]? 9 | let serviceData: [ServiceDataKeys: ServiceValues]? 10 | 11 | enum CodingKeys: String, CodingKey { 12 | case type 13 | case domain 14 | case service 15 | case serviceData = "service_data" 16 | case target 17 | } 18 | 19 | init(serviceID: ServiceID, 20 | target: [ServiceTargetKeys: ServiceValues]?, 21 | serviceData: [ServiceDataKeys: ServiceValues]?) { 22 | type = "call_service" 23 | 24 | let components = serviceID.rawValue.split(separator: ".") 25 | if components.count == 2 { 26 | domain = String(components[0]) 27 | service = String(components[1]) 28 | } else { 29 | domain = serviceID.rawValue 30 | service = serviceID.rawValue 31 | Log.error("ServiceID \(serviceID) does not specify domain and service") 32 | } 33 | 34 | self.target = target 35 | self.serviceData = serviceData 36 | } 37 | 38 | init(serviceID: ServiceID, serviceData: [ServiceDataKeys: String]?) { 39 | self.init(serviceID: serviceID, target: nil, serviceData: serviceData?.mapValues { .string($0) }) 40 | } 41 | 42 | func encode(to encoder: Encoder) throws { 43 | var container = encoder.container(keyedBy: CodingKeys.self) 44 | try container.encode(type, forKey: .type) 45 | try container.encode(domain, forKey: .domain) 46 | try container.encode(service, forKey: .service) 47 | 48 | if let target { 49 | var targetContainer = container.nestedContainer(keyedBy: ServiceTargetKeys.self, forKey: .target) 50 | for (key, value) in target { 51 | try targetContainer.encode(value, forKey: key) 52 | } 53 | } 54 | 55 | if let serviceData { 56 | var serviceDataContainer = container.nestedContainer(keyedBy: ServiceDataKeys.self, forKey: .serviceData) 57 | for (key, value) in serviceData { 58 | try serviceDataContainer.encode(value, forKey: key) 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /IntelliNest/Model/Websocket/ServiceData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceData.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-07-10. 6 | // 7 | 8 | import Foundation 9 | import ShipBookSDK 10 | 11 | class ServiceData: Encodable {} 12 | 13 | @MainActor 14 | class LightServiceData: ServiceData { 15 | let brightness: Int 16 | init(brightness: Int) { 17 | self.brightness = brightness 18 | } 19 | 20 | override func encode(to encoder: Encoder) throws { 21 | var container = encoder.container(keyedBy: CodingKeys.self) 22 | try container.encode(brightness, forKey: .brightness) 23 | } 24 | 25 | private enum CodingKeys: String, CodingKey { 26 | case brightness 27 | } 28 | } 29 | 30 | @MainActor 31 | class InputNumberServiceData: ServiceData { 32 | let value: Double 33 | 34 | init(value: Double) { 35 | self.value = value 36 | } 37 | 38 | override func encode(to encoder: Encoder) throws { 39 | var container = encoder.container(keyedBy: CodingKeys.self) 40 | try container.encode(value, forKey: .value) 41 | } 42 | 43 | private enum CodingKeys: String, CodingKey { 44 | case value 45 | } 46 | } 47 | 48 | @MainActor 49 | class DateTimeServiceData: ServiceData { 50 | let date: String? 51 | let time: String? 52 | 53 | init(date: Date) { 54 | let dateFormatter = DateFormatter() 55 | 56 | dateFormatter.dateFormat = "yyyy-MM-dd" 57 | self.date = dateFormatter.string(from: date) 58 | 59 | dateFormatter.dateFormat = "HH:mm:ss" 60 | time = dateFormatter.string(from: date) 61 | } 62 | 63 | override func encode(to encoder: Encoder) throws { 64 | var container = encoder.container(keyedBy: CodingKeys.self) 65 | 66 | if let date { 67 | try container.encode(date, forKey: .date) 68 | } 69 | 70 | if let time { 71 | try container.encode(time, forKey: .time) 72 | } 73 | } 74 | 75 | private enum CodingKeys: String, CodingKey { 76 | case date 77 | case time 78 | } 79 | } 80 | 81 | class ScriptServiceData: ServiceData { 82 | var variables: [ScriptVariableKeys: String] 83 | 84 | init(variables: [ScriptVariableKeys: String]) { 85 | self.variables = variables 86 | } 87 | 88 | override func encode(to encoder: any Encoder) throws { 89 | var container = encoder.container(keyedBy: CodingKeys.self) 90 | for (key, value) in variables { 91 | if let codingKey = CodingKeys(rawValue: key.rawValue) { 92 | try container.encode(value, forKey: codingKey) 93 | } else { 94 | // Log.error("ScriptServiceData failed to encode key: \(key.rawValue) with value: \(value)") 95 | } 96 | } 97 | } 98 | 99 | private enum CodingKeys: String, CodingKey { 100 | case entityID = "entity_id" 101 | } 102 | } 103 | 104 | class EmptyServiceData: ServiceData {} 105 | -------------------------------------------------------------------------------- /IntelliNest/Model/Websocket/SubscribeRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubscribeRequest.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-06-28. 6 | // 7 | 8 | import Foundation 9 | 10 | enum EventType: String, Encodable { 11 | case stateChange = "state_changed" 12 | } 13 | 14 | struct SubscribeRequest: Encodable { 15 | let type = "subscribe_events" 16 | let eventType: EventType 17 | 18 | enum CodingKeys: String, CodingKey { 19 | case type 20 | case eventType = "event_type" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /IntelliNest/Model/Websocket/UpdateEntityRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateEntityRequest.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-06-25. 6 | // 7 | 8 | import Foundation 9 | 10 | struct UpdateEntityRequest: Encodable { 11 | let type = "call_service" 12 | let domain: Domain 13 | let service: Action 14 | var serviceData: ServiceData 15 | let target: Target 16 | 17 | enum CodingKeys: String, CodingKey { 18 | case type 19 | case domain 20 | case service 21 | case serviceData = "service_data" 22 | case target 23 | } 24 | 25 | struct Target: Encodable { 26 | let entityIds: [String] 27 | 28 | enum CodingKeys: String, CodingKey { 29 | case entityIds = "entity_id" 30 | } 31 | } 32 | 33 | init(domain: Domain, action: Action, serviceData: ServiceData? = nil, entityIds: [EntityId]) { 34 | self.domain = domain 35 | service = action 36 | self.serviceData = serviceData ?? EmptyServiceData() 37 | target = Target(entityIds: entityIds.map(\.rawValue)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /IntelliNest/Model/YaleLock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YaleLock.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-05-18. 6 | // 7 | 8 | import Foundation 9 | 10 | struct YaleLock: Lockable, Decodable { 11 | var expectedStateSetDate: Date? 12 | let id: LockID 13 | var lockState: LockState = .unknown { didSet { 14 | if lockState == expectedState || expectedStateIsOld { 15 | expectedState = .unknown 16 | } 17 | }} 18 | var expectedState: LockState = .unknown { 19 | didSet { 20 | self.expectedStateSetDate = Date() 21 | } 22 | } 23 | 24 | var doorState: DoorState = .closed 25 | } 26 | -------------------------------------------------------------------------------- /IntelliNest/Model/YaleLockResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YaleLockResponse.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-05-18. 6 | // 7 | 8 | import Foundation 9 | 10 | struct LockStatus: Decodable { 11 | let status: String 12 | let dateTime: String 13 | let isLockStatusChanged: Bool 14 | let valid: Bool 15 | let doorState: DoorState 16 | } 17 | 18 | struct YaleLockResponse: Decodable { 19 | let lockStatus: LockStatus 20 | 21 | enum CodingKeys: String, CodingKey { 22 | case lockStatus = "LockStatus" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /IntelliNest/Navigation/Destination.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Destination.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-04-30. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Destination: String { 11 | case electricity 12 | case home 13 | case heaters 14 | case lynk 15 | case corridorHeaterDetails 16 | case playroomHeaterDetails 17 | case roborock 18 | case lights 19 | 20 | var title: String { 21 | switch self { 22 | case .electricity: 23 | "Ström" 24 | case .home: 25 | "Hem" 26 | case .heaters: 27 | "Värmepumpar" 28 | case .lynk: 29 | "Lynk" 30 | case .corridorHeaterDetails: 31 | "Korridoren" 32 | case .playroomHeaterDetails: 33 | "Lekrummet" 34 | case .roborock: 35 | "Roborock" 36 | case .lights: 37 | "Lampor" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /IntelliNest/Navigation/NavigatorHelpers.swif: -------------------------------------------------------------------------------- 1 | import Foundation 2 | extension Navigator 3 | -------------------------------------------------------------------------------- /IntelliNest/Navigation/NavigatorHelpers.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Navigator { 4 | func pop() { 5 | navigationPath.removeLast() 6 | } 7 | 8 | func show(destination: Destination) -> some View { 9 | Group { 10 | switch destination { 11 | case .electricity: 12 | showElectricityView() 13 | case .home: 14 | Text("Not implemented show for home") 15 | case .heaters: 16 | showHeatersView() 17 | case .lynk: 18 | showLynkView() 19 | case .corridorHeaterDetails: 20 | showHeaterDetailsView(heaterID: .heaterCorridor) 21 | case .playroomHeaterDetails: 22 | showHeaterDetailsView(heaterID: .heaterPlayroom) 23 | case .roborock: 24 | showRoborockView() 25 | case .lights: 26 | showLightsView() 27 | } 28 | } 29 | .backgroundModifier() 30 | } 31 | 32 | func push(_ destination: Destination) { 33 | if currentDestination != destination { 34 | Task { 35 | if destination == .home { 36 | navigationPath = [] 37 | } else { 38 | navigationPath.append(destination) 39 | } 40 | 41 | await reloadCurrentModel() 42 | } 43 | } 44 | } 45 | 46 | func showElectricityView() -> ElectricityView { 47 | ElectricityView(viewModel: electricityViewModel) 48 | } 49 | 50 | func showHomeView() -> HomeView { 51 | HomeView(viewModel: homeViewModel) 52 | } 53 | 54 | func showHeatersView() -> HeatersView { 55 | HeatersView(viewModel: heatersViewModel) 56 | } 57 | 58 | func showLynkView() -> LynkView { 59 | LynkView(viewModel: lynkViewModel) 60 | } 61 | 62 | func showHeaterDetailsView(heaterID: EntityId) -> DetailedHeaterView { 63 | if heaterID == .heaterCorridor { 64 | DetailedHeaterView(viewModel: heatersViewModel, selectedHeater: .corridor) 65 | } else { 66 | DetailedHeaterView(viewModel: heatersViewModel, selectedHeater: .playroom) 67 | } 68 | } 69 | 70 | func showRoborockView() -> RoborockView { 71 | RoborockView(viewModel: roborockViewModel) 72 | } 73 | 74 | func showLightsView() -> LightsView { 75 | LightsView(viewModel: lightsViewModel) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /IntelliNest/Navigation/ToolbarBackButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolbarBackButton.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-05-02. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ToolbarBackButton: View { 11 | @Environment(\.dismiss) private var dismiss 12 | 13 | var body: some View { 14 | Button { 15 | dismiss() 16 | } label: { 17 | Image(systemName: "chevron.left") 18 | .foregroundColor(.white) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Navigation/ToolbarItems.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolbarItems.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-05-02. 6 | // 7 | 8 | import ShipBookSDK 9 | import SwiftUI 10 | 11 | struct ToolBarConnectionStateView: View { 12 | @ObservedObject var urlCreator: URLCreator 13 | 14 | var body: some View { 15 | Button { 16 | Task { @MainActor in 17 | await urlCreator.updateConnectionState() 18 | } 19 | } label: { 20 | Group { 21 | switch urlCreator.connectionState { 22 | case .local: 23 | Image(systemName: "wifi") 24 | case .internet: 25 | Image(systemName: "globe") 26 | case .disconnected: 27 | Image(systemName: "wifi.exclamationmark") 28 | case .loading: 29 | ProgressView() 30 | .progressViewStyle(CircularProgressViewStyle()) 31 | case .unset: 32 | Text("?") 33 | } 34 | } 35 | .foregroundColor(.white) 36 | .padding(.trailing) 37 | } 38 | } 39 | } 40 | 41 | struct ToolbarTitleView: View { 42 | var destination: Destination 43 | 44 | var body: some View { 45 | Text(destination.title) 46 | .foregroundColor(.white) 47 | .font(.headline) 48 | } 49 | } 50 | 51 | struct ToolbarReloadButtonView: View { 52 | var destination: Destination 53 | var navigator: Navigator? 54 | var reloadAction: MainActorAsyncVoidClosure? 55 | @State var isLoading = false 56 | 57 | var body: some View { 58 | Button { 59 | Task { @MainActor in 60 | isLoading = true 61 | if let reloadAction { 62 | await reloadAction() 63 | } else if let navigator { 64 | await navigator.reloadConnection() 65 | await navigator.reload(for: destination) 66 | } else { 67 | Log.error("Missing reload action in ToolbarReloadButton for \(destination)") 68 | } 69 | isLoading = false 70 | } 71 | } label: { 72 | Image(imageName: .refresh) 73 | .foregroundColor(.white) 74 | .rotationEffect(.degrees(isLoading ? 360 : 0)) 75 | .animation(isLoading ? .linear(duration: 1) : nil, value: isLoading) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /IntelliNest/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /IntelliNest/Services/GeofenceManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeofenceManager.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2024-01-22. 6 | // 7 | 8 | import CoreLocation 9 | import Foundation 10 | import ShipBookSDK 11 | import UserNotifications 12 | 13 | class GeofenceManager: NSObject { 14 | private let locationManager = CLLocationManager() 15 | private let didEnterHomeAction: VoidClosure 16 | private let didExitHomeAction: VoidClosure 17 | 18 | init(didEnterHomeAction: @escaping VoidClosure, didExitHomeAction: @escaping VoidClosure) { 19 | self.didEnterHomeAction = didEnterHomeAction 20 | self.didExitHomeAction = didExitHomeAction 21 | super.init() 22 | locationManager.delegate = self 23 | locationManager.desiredAccuracy = kCLLocationAccuracyBest 24 | locationManager.requestAlwaysAuthorization() 25 | } 26 | 27 | func configureGeoFence(homeCoordinates: Coordinates) { 28 | for region in locationManager.monitoredRegions { 29 | if let circularRegion = region as? CLCircularRegion { 30 | locationManager.stopMonitoring(for: circularRegion) 31 | } 32 | } 33 | 34 | let geofenceRegion = CLCircularRegion(center: homeCoordinates.toCLLocationCoordinate2D(), 35 | radius: 50, 36 | identifier: "HomeRegion") 37 | geofenceRegion.notifyOnExit = true 38 | geofenceRegion.notifyOnEntry = true 39 | 40 | startMonitoring(geofenceRegion: geofenceRegion) 41 | } 42 | 43 | private func startMonitoring(geofenceRegion: CLCircularRegion) { 44 | if CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) { 45 | locationManager.startMonitoring(for: geofenceRegion) 46 | } 47 | } 48 | } 49 | 50 | extension GeofenceManager: CLLocationManagerDelegate { 51 | func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { 52 | if region is CLCircularRegion { 53 | if !UserDefaults.standard.bool(forKey: StorageKeys.isHome.rawValue) { 54 | UserDefaults.standard.set(true, forKey: StorageKeys.isHome.rawValue) 55 | didEnterHomeAction() 56 | } 57 | } 58 | } 59 | 60 | func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) { 61 | if region is CLCircularRegion { 62 | if UserDefaults.standard.bool(forKey: StorageKeys.isHome.rawValue) { 63 | UserDefaults.standard.set(false, forKey: StorageKeys.isHome.rawValue) 64 | didExitHomeAction() 65 | } 66 | } 67 | } 68 | 69 | func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { 70 | Log.error("Location manager error: \(error)") 71 | } 72 | 73 | func locationManager(_ manager: CLLocationManager, monitoringDidFailFor region: CLRegion?, withError error: Error) { 74 | Log.error("Location manager failure: \(error)") 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /IntelliNest/Services/NetworkTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkTask.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-05-25. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol NetworkTask { 11 | func resume() 12 | } 13 | 14 | extension URLSessionDataTask: NetworkTask {} 15 | -------------------------------------------------------------------------------- /IntelliNest/Services/NotificationService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationService.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2024-01-25. 6 | // 7 | 8 | import Foundation 9 | import ShipBookSDK 10 | import UserNotifications 11 | 12 | class NotificationService { 13 | init() {} 14 | 15 | static func sendNotification(title: String, message: String, identifier: String) { 16 | let content = UNMutableNotificationContent() 17 | content.title = title 18 | content.body = message 19 | content.sound = UNNotificationSound.default 20 | 21 | let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) 22 | UNUserNotificationCenter.current().add(request) { error in 23 | if let error { 24 | Log.error("Error scheduling notification: \(error)") 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /IntelliNest/Services/PreviewProvideUtil.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewProvideUtil.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2024-01-25. 6 | // 7 | 8 | import Foundation 9 | 10 | @MainActor 11 | class PreviewProviderUtil { 12 | static var urlCreator = URLCreator() 13 | static var restAPIService = RestAPIService(urlCreator: PreviewProviderUtil.urlCreator, setErrorBannerText: { _, _ in }, 14 | repeatReloadAction: { _ in }) 15 | static var electricityViewModel = ElectricityViewModel(sonnenBattery: .init(entityID: .sonnenBattery), 16 | restAPIService: PreviewProviderUtil.restAPIService) 17 | static var heatersViewModel = HeatersViewModel(restAPIService: restAPIService, showHeaterDetails: { _ in }) 18 | static var lynkViewModel = LynkViewModel(restAPIService: restAPIService) 19 | 20 | private init() {} 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/AppIntentVocabulary.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IntentPhrases 6 | 7 | 8 | IntentName 9 | INSendMessageIntent 10 | IntentExamples 11 | 12 | Text example 13 | 14 | 15 | 16 | IntentName 17 | INSetMessageAttributeIntent 18 | IntentExamples 19 | 20 | Text example attribute 21 | 22 | 23 | 24 | IntentName 25 | INSearchForMessagesIntent 26 | IntentExamples 27 | 28 | Example message 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/App Store.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/App Store.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad App Icon iOS 7,12 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad App Icon iOS 7,12 1.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad App Icon iOS 7,12@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad App Icon iOS 7,12@2x.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Pro Icon iOS 9,12@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Pro Icon iOS 9,12@2x.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Settings 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Settings 1.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Settings.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Settings@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Settings@2x 1.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Settings@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Settings@2x.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Spotlight iOS 7,12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Spotlight iOS 7,12.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Spotlight iOS 7,12@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Spotlight iOS 7,12@2x 1.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Spotlight iOS 7,12@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad Spotlight iOS 7,12@2x.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad notification iOS 7,12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad notification iOS 7,12.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad notification iOS 7,12@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPad notification iOS 7,12@2x.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone App Icon iOS 5,6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone App Icon iOS 5,6.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone App Icon iOS 5,6@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone App Icon iOS 5,6@2x.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone App Icon iOS 7,12@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone App Icon iOS 7,12@2x.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone App Icon iOS 7,12@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone App Icon iOS 7,12@3x.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone Spotlight iOS 5,6 & Settings@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone Spotlight iOS 5,6 & Settings@3x.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone Spotlight iOS 7,12@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone Spotlight iOS 7,12@3x.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone notification iOS 7,12@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone notification iOS 7,12@2x.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone notification iOS 7,12@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/AppIcon.appiconset/iPhone notification iOS 7,12@3x.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/Images.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "idiom" : "universal", 13 | "scale" : "3x" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/aircondition.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "heater.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/aircondition.imageset/heater.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/aircondition.imageset/heater.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/defrost.filled.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "defrost.filled.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "preserves-vector-representation" : true, 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/defrost.filled.imageset/defrost.filled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/defrost.filled.imageset/defrost.filled.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/ev-plug-ccs2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "ev-plug-ccs2.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/ev-plug-ccs2.imageset/ev-plug-ccs2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/ev-plug-ccs2.imageset/ev-plug-ccs2.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/ev-plug-type2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "ev-plug-type2.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/ev-plug-type2.imageset/ev-plug-type2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/ev-plug-type2.imageset/ev-plug-type2.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/floorplan.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "floorplan.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/floorplan.imageset/floorplan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/floorplan.imageset/floorplan.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/gym.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "gym.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/gym.imageset/gym.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/gym.imageset/gym.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/hallway.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "hallway.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/hallway.imageset/hallway.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/hallway.imageset/hallway.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/powergrid.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "powergrid.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/powergrid.imageset/powergrid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/powergrid.imageset/powergrid.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/refresh.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "icons8-iOS Glyph-tovbiOioOGAO-50-ffffff.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/refresh.imageset/icons8-iOS Glyph-tovbiOioOGAO-50-ffffff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/refresh.imageset/icons8-iOS Glyph-tovbiOioOGAO-50-ffffff.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/roborocks7.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_128x128.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/roborocks7.imageset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/roborocks7.imageset/icon_128x128.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/seatheater.filled.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "seatheater.filled.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "preserves-vector-representation" : true, 23 | "template-rendering-intent" : "template" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/seatheater.filled.imageset/seatheater.filled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/seatheater.filled.imageset/seatheater.filled.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/settings.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "settings.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/settings.imageset/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/settings.imageset/settings.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/solarpanel.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "solarpanel.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/solarpanel.imageset/solarpanel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/solarpanel.imageset/solarpanel.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/vince.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "vince.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/vince.imageset/vince.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/vince.imageset/vince.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/washing.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "washing.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Assets.xcassets/washing.imageset/washing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliNest/Supporting Files/Assets.xcassets/washing.imageset/washing.png -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleURLTypes 6 | 7 | 8 | CFBundleURLName 9 | se.laross.intellinest 10 | CFBundleURLSchemes 11 | 12 | IntelliNest 13 | 14 | 15 | 16 | UIBackgroundModes 17 | 18 | fetch 19 | 20 | EXTERNAL_URL 21 | $(EXTERNAL_URL) 22 | ITSAppUsesNonExemptEncryption 23 | 24 | LOCAL_SSID 25 | $(LOCAL_SSID) 26 | NSUserActivityTypes 27 | 28 | SECRET_HASS_TOKEN 29 | $(SECRET_HASS_TOKEN) 30 | SECRET_HASS_TOKEN_SARAH 31 | $(SECRET_HASS_TOKEN_SARAH) 32 | SECRET_RTSP_STREAM_CAMERA_BACK 33 | $(SECRET_RTSP_STREAM_CAMERA_BACK) 34 | SECRET_RTSP_STREAM_CAMERA_CARPORT 35 | $(SECRET_RTSP_STREAM_CAMERA_CARPORT) 36 | SECRET_RTSP_STREAM_CAMERA_FRONT 37 | $(SECRET_RTSP_STREAM_CAMERA_FRONT) 38 | SECRET_RTSP_STREAM_CAMERA_VINCE 39 | $(SECRET_RTSP_STREAM_CAMERA_VINCE) 40 | SECRET_SHIP_BOOK_APP_ID 41 | $(SECRET_SHIP_BOOK_APP_ID) 42 | SECRET_SHIP_BOOK_APP_KEY 43 | $(SECRET_SHIP_BOOK_APP_KEY) 44 | SECRET_YALE_API_KEY 45 | $(SECRET_YALE_API_KEY) 46 | SECRET_YALE_API_URL 47 | $(SECRET_YALE_API_URL) 48 | UIApplicationSceneManifest 49 | 50 | UIApplicationSupportsMultipleScenes 51 | 52 | UISceneConfigurations 53 | 54 | 55 | UIApplicationShortcutItems 56 | 57 | 58 | UIApplicationShortcutItemIconSymbolName 59 | thermometer 60 | UIApplicationShortcutItemTitle 61 | Starta bilens klimat 62 | UIApplicationShortcutItemType 63 | CarHeater 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /IntelliNest/Supporting Files/IntelliNest.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.networking.HotspotConfiguration 8 | 9 | com.apple.developer.networking.wifi-info 10 | 11 | com.apple.security.application-groups 12 | 13 | group.se.laross.intellinest.shared 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/Actions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Actions.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-08-23. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Action: String, Codable { 11 | case kiaUpdate = "update" 12 | case kiaForceUpdate = "force_update" 13 | case kiaStopCharge = "stop_charge" 14 | case kiaStartCharge = "start_charge" 15 | case kiaStartClimate = "start_climate" 16 | case stopClimate = "stop_climate" 17 | case kiaLimitCharger = "set_charge_limits" 18 | case lock 19 | case unlock 20 | case turnOn = "turn_on" 21 | case turnOff = "turn_off" 22 | case locate 23 | case register 24 | case snapshot 25 | case stop 26 | case start 27 | case lynkReload = "force_update_data" 28 | case lockDoors = "lock_doors" 29 | case unlockDoors = "unlock_doors" 30 | case startFlashLights = "start_flash_lights" 31 | case stopFlashLights = "stop_flash_lights" 32 | case setDateTime = "set_datetime" 33 | case setValue = "set_value" 34 | case setVaneHorizontal = "set_vane_horizontal" 35 | case setVaneVertical = "set_vane_vertical" 36 | case setFanMode = "set_fan_mode" 37 | case setPresetMode = "set_preset_mode" 38 | case startEngine = "start_engine" 39 | case stopEngine = "stop_engine" 40 | case setTemperature = "set_temperature" 41 | case setPercentage = "set_percentage" 42 | case setHvacMode = "set_hvac_mode" 43 | case updateEntity = "update_entity" 44 | case sonnenOperationMode = "sonnen_put_config_operation_mode" 45 | case sonnenCharge = "sonnen_charge" 46 | case sonnenDischarge = "sonnen_discharge" 47 | } 48 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/Closures.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Closures.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-10-07. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias VoidClosure = () -> Void 11 | typealias MainActorVoidClosure = @MainActor () -> Void 12 | typealias MainActorEntityIDClosure = @MainActor (EntityId) -> Void 13 | typealias AsyncVoidClosure = () async -> Void 14 | typealias MainActorAsyncVoidClosure = @MainActor () async -> Void 15 | typealias StringClosure = (String) -> Void 16 | typealias StringStringClosure = (String, String) -> Void 17 | typealias DoubleClosure = (Double) -> Void 18 | typealias IntClosure = (Int) -> Void 19 | typealias HeaterDoubleClosure = @MainActor (HeaterEntity, Double) -> Void 20 | typealias HeaterStringClosure = @MainActor (HeaterEntity, HvacMode) -> Void 21 | typealias HeaterFanModeClosure = @MainActor (HeaterEntity, HeaterFanMode) -> Void 22 | typealias FanModeClosure = (HeaterFanMode) -> Void 23 | typealias HeaterHorizontalModeClosure = @MainActor (HeaterEntity, HeaterHorizontalMode) -> Void 24 | typealias HorizontalModeClosure = (HeaterHorizontalMode) -> Void 25 | typealias HeaterVerticalModeClosure = @MainActor (HeaterEntity, HeaterVerticalMode) -> Void 26 | typealias VerticalModeClosure = (HeaterVerticalMode) -> Void 27 | typealias EntityClosure = (Entity) -> Void 28 | typealias MainActorEntityClosure = @MainActor (Entity) -> Void 29 | typealias AsyncEntityClosure = (Entity) async -> Void 30 | typealias ScriptIDClosure = (ScriptID) -> Void 31 | typealias EntityIdDoubleClosure = @MainActor (EntityId, Double) -> Void 32 | typealias AsyncLightClosure = (LightEntity) async -> Void 33 | typealias AsyncSlideableClosure = @MainActor (Slideable) async -> Void 34 | typealias SlideableIntClosure = @MainActor (Slideable, Int) -> Void 35 | typealias AsyncSlideableIntClosure = (Slideable, Int) async -> Void 36 | typealias DestinationClosure = (Destination) -> Void 37 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/DeviceID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeviceID.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-02-03. 6 | // 7 | 8 | import Foundation 9 | 10 | enum DeviceID: String, Decodable { 11 | case eniro = "e6bf834649633a87c6c50dd51dbe9421" 12 | } 13 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/Domain.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Domain.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-08-23. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Domain: String, Encodable { 11 | case apnsToken 12 | case camera 13 | case climate 14 | case fan 15 | case melcloud 16 | case switchDomain = "switch" 17 | case lock 18 | case script 19 | case automation 20 | case homeassistant 21 | case restCommand = "rest_command" 22 | case leaf = "nissan_carwings" 23 | case light 24 | case lynkco 25 | case inputBoolean = "input_boolean" 26 | case inputDateTime = "input_datetime" 27 | case inputNumber = "input_number" 28 | case sensor 29 | case binarySensor = "binary_sensor" 30 | case kiaUvo = "kia_uvo" 31 | case vacuum 32 | case unknown 33 | } 34 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/FullScreenBackgroundOverlay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FullScreenBackgroundOverlay.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-02-22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FullScreenBackgroundOverlay: View { 11 | var body: some View { 12 | Rectangle() 13 | .foregroundStyle(Color.bodyColor) 14 | .opacity(0.5) 15 | .edgesIgnoringSafeArea(.all) 16 | } 17 | } 18 | 19 | struct FullScreenBackgroundOverlay_Previews: PreviewProvider { 20 | static var previews: some View { 21 | FullScreenBackgroundOverlay() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/HTTPMethod.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HTTPMethod.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-05-18. 6 | // 7 | 8 | import Foundation 9 | 10 | enum HTTPMethod: String, Equatable { 11 | case get 12 | case post 13 | case put 14 | case delete 15 | } 16 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/Heater.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Heater.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-08-23. 6 | // 7 | 8 | import Foundation 9 | 10 | enum HvacMode: String { 11 | case off 12 | case heat 13 | case cool 14 | } 15 | 16 | enum HeaterFanMode: String, Decodable { 17 | case auto 18 | case one = "1" 19 | case two = "2" 20 | case three = "3" 21 | case four = "4" 22 | case five = "5" 23 | case unknown 24 | } 25 | 26 | enum HeaterHorizontalMode: String, Encodable { 27 | case auto 28 | case oneLeft = "1_left" 29 | case two = "2" 30 | case three = "3" 31 | case four = "4" 32 | case fiveRight = "5_right" 33 | case swing 34 | case split 35 | case unknown 36 | } 37 | 38 | enum HeaterVerticalMode: String, Encodable { 39 | case auto 40 | case highest = "1_up" 41 | case position2 = "2" 42 | case position3 = "3" 43 | case position4 = "4" 44 | case lowest = "5_down" 45 | case swing 46 | case unknown 47 | } 48 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/JSONKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONKey.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-05-18. 6 | // 7 | 8 | import Foundation 9 | 10 | enum JSONKey: String, Equatable, Codable, Hashable { 11 | case invalid 12 | case appData = "app_data" 13 | case appID = "app_id" 14 | case appName = "app_name" 15 | case appVersion = "app_version" 16 | case data 17 | case entityID = "entity_id" 18 | case deviceID = "device_id" 19 | case deviceName = "device_name" 20 | case brightness 21 | case dateTime = "datetime" 22 | case manufacturer 23 | case model 24 | case osName = "os_name" 25 | case osVersion = "os_version" 26 | case time 27 | case percentage 28 | case presetMode = "preset_mode" 29 | case temperature 30 | case hvacMode = "hvac_mode" 31 | case fanMode = "fan_mode" 32 | case position 33 | case duration 34 | case climate 35 | case defrost 36 | case heating 37 | case flseat 38 | case frseat 39 | case filename 40 | case variables 41 | case acLimit = "ac_limit" 42 | case dcLimit = "dc_limit" 43 | case supportsEncryption = "supports_encryption" 44 | case pushToken = "push_token" 45 | case pushURL = "push_url" 46 | case operationMode 47 | case yaleAccessTokenFull = "yale_access_token_full" 48 | case value 49 | case vin 50 | case watt 51 | } 52 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/LockID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LockID.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-05-18. 6 | // 7 | 8 | import Foundation 9 | 10 | enum LockID: String, Decodable { 11 | case sideDoor = "7A274D712EF3B541A584EC739A0502A2" 12 | case frontDoor = "57034E453D405F4C8F9085C19EDF14D1" 13 | case storageDoor = "storage" 14 | case lynkDoor = "lynk" 15 | } 16 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/ScriptID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScriptID.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-07-11. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ScriptID: String, Decodable, CaseIterable { 11 | case roborockDock = "script.docka_bob" 12 | case roborockSendToBin = "script.bob_send_to_bin" 13 | case roborockManualEmpty = "script.roborock_manual_empty" 14 | case roborockKitchen = "script.dammsug_koket" 15 | case roborockLaundry = "script.dammsug_tvattstugan" 16 | case roborockCorridor = "script.dammsug_korridoren" 17 | case roborockHallway = "script.dammsug_hallen" 18 | case roborockBedroom = "script.dammsug_sovrummet" 19 | case roborockGym = "script.dammsug_gymmet" 20 | case roborockLivingroom = "script.dammsug_vardagsrummet" 21 | case roborockVinceRoom = "script.dammsug_vince_rum" 22 | case roborockKitchenTable = "script.dammsug_matbord" 23 | case roborockKitchenStove = "script.dammsug_matlagning" 24 | case saveClimateState = "script.save_climate_state" 25 | case eniroStartClimate = "script.kia_climate_control" 26 | case eniroTurnOffStartClimate = "script.turn_off_kia_climate_control" 27 | case lynkStartClimate = "script.lynk_start_climate" 28 | case lynkStopClimate = "script.lynk_stop_climate" 29 | case lynkStartEngine = "script.lynk_start_engine" 30 | case lynkStopEngine = "script.lynk_stop_engine" 31 | case easeeToggle = "script.toggle_easee" 32 | } 33 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/ScriptVariableKeys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScriptVariableKeys.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-06-16. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ScriptVariableKeys: String, Equatable, Codable, Hashable { 11 | case entityID = "entity_id" 12 | } 13 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/ServiceDataKeys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceDataKeys.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-11-17. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ServiceDataKeys: String, Equatable, Codable, Hashable, CodingKey { 11 | case service 12 | case entityID = "entity_id" 13 | case deviceID = "device_id" 14 | case dcLimit = "dc_limit" 15 | case acLimit = "ac_limit" 16 | case temperature 17 | case hvacMode = "hvac_mode" 18 | case fanMode = "fan_mode" 19 | case position 20 | case date 21 | case time 22 | case datetime 23 | case watt 24 | case operationMode 25 | } 26 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/ServiceID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceID.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-11-17. 6 | // 7 | 8 | import Foundation 9 | import ShipBookSDK 10 | 11 | enum ServiceID: String, Decodable, CaseIterable { 12 | case kiaForceUpdate = "kia_uvo.force_update" 13 | case kiaUpdate = "kia_uvo.update" 14 | case kiaLock = "kia_uvo.lock" 15 | case kiaUnlock = "kia_uvo.unlock" 16 | case kiaStartCharge = "kia_uvo.start_charge" 17 | case kiaStopCharge = "kia_uvo.stop_charge" 18 | case kiaChargeLimit = "kia_uvo.set_charge_limits" 19 | case kiaStopClimate = "kia_uvo.stop_climate" 20 | case leafUpdate = "nissan_carwings.update" 21 | case leafStartClimate = "nissan_carwings.start_climate" 22 | case leafStopClimate = "nissan_carwings.stop_climate" 23 | case leafStartCharging = "nissan_carwings.start_charging" 24 | case lynkReload = "lynkco.force_update_data" 25 | case lynkLockDoors = "lynkco.lock_doors" 26 | case lynkUnlockDoors = "lynkco.unlock_doors" 27 | case lynkFlashStart = "lynkco.start_flash_lights" 28 | case lynkFlashStop = "lynkco.stop_flash_lights" 29 | case lynkStartEngine = "lynkco.start_engine" 30 | case lynkStopEngine = "lynkco.stop_engine" 31 | case cameraStream = "camera/stream" 32 | case automationTurnOn = "automation.turn_on" 33 | case automationTurnOff = "automation.turn_off" 34 | case boolTurnOn = "input_boolean.turn_on" 35 | case boolTurnOff = "input_boolean.turn_off" 36 | case updateEntity = "homeassistant.update_entity" 37 | case heaterTemperature = "climate.set_temperature" 38 | case heaterHvacMode = "climate.set_hvac_mode" 39 | case heaterFanMode = "climate.set_fan_mode" 40 | case heaterHorizontal = "melcloud.set_vane_horizontal" 41 | case heaterVertical = "melcloud.set_vane_vertical" 42 | case setDateTime = "input_datetime.set_datetime" 43 | case sonnenOperationMode = "rest_command.sonnen_put_config_operation_mode" 44 | case sonnenCharge = "rest_command.sonnen_charge" 45 | case sonnenDischarge = "rest_command.sonnen_discharge" 46 | 47 | @MainActor 48 | var toAction: Action? { 49 | if let action = Action(rawValue: rawValue.components(separatedBy: ".").last ?? "") { 50 | return action 51 | } else { 52 | Log.error("Failed to create action from ServiceID: \(rawValue)") 53 | return nil 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/ServiceTargetKeys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceTargetKeys.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2024-01-11. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ServiceTargetKeys: String, CodingKey { 11 | case entityID = "entity_id" 12 | } 13 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/ServiceValues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceValues.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-11-17. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ServiceValues: Encodable { 11 | case string(String) 12 | case int(Int) 13 | case double(Double) 14 | case stringArray([String]) 15 | 16 | func encode(to encoder: Encoder) throws { 17 | var container = encoder.singleValueContainer() 18 | switch self { 19 | case let .string(stringValue): 20 | try container.encode(stringValue) 21 | case let .int(intValue): 22 | try container.encode(intValue) 23 | case let .double(doubleValue): 24 | try container.encode(doubleValue) 25 | case let .stringArray(stringArrayValue): 26 | try container.encode(stringArrayValue) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Constants/StorageKeys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StorageKeys.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-05-18. 6 | // 7 | 8 | import Foundation 9 | 10 | enum StorageKeys: String { 11 | case homeCoordinatesDual 12 | case enteredHomeTime 13 | case isHome 14 | case lynkReloadTime 15 | case leafReloadTime 16 | case sarahPills 17 | case userInitials 18 | case webhookID 19 | case yaleAccessToken 20 | } 21 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/CalendarExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarExtension.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-02-08. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Calendar { 11 | static var currentHour: Int { 12 | Calendar.current.component(.hour, from: Date()) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/CollectionExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionExtension.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-10-22. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Collection { 11 | var isNotEmpty: Bool { 12 | !isEmpty 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/DateExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DateExtension.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-03-05. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Date { 11 | static func fromISO8601(_ dateString: String?) -> Date? { 12 | guard let dateString else { 13 | return nil 14 | } 15 | 16 | let dateFormatter = DateFormatter() 17 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ" 18 | return dateFormatter.date(from: dateString) 19 | } 20 | 21 | func daysRemainingDescription() -> String? { 22 | let currentDate = Calendar.current.startOfDay(for: Date()) 23 | let calendar = Calendar.current 24 | let components = calendar.dateComponents([.day], from: currentDate, to: self) 25 | 26 | if let daysRemaining = components.day { 27 | switch daysRemaining { 28 | case 0: 29 | return "idag" 30 | case 1: 31 | return "imorgon" 32 | default: 33 | break 34 | } 35 | } 36 | 37 | return nil 38 | } 39 | 40 | var humanReadable: String { 41 | let now = Date() 42 | let calendar = Calendar.current 43 | 44 | if let minutesAgo = calendar.dateComponents([.minute], from: self, to: now).minute, minutesAgo < 60 { 45 | if minutesAgo == 0 { 46 | return "Just now" 47 | } else if minutesAgo == 1 { 48 | return "\(minutesAgo) minute ago" 49 | } else { 50 | return "\(minutesAgo) minutes ago" 51 | } 52 | } 53 | 54 | if let hoursAgo = calendar.dateComponents([.hour], from: self, to: now).hour, hoursAgo < 24 { 55 | return "\(hoursAgo) \(hoursAgo == 1 ? "hour" : "hours") ago" 56 | } 57 | 58 | if calendar.isDateInYesterday(self) { 59 | return "Yesterday" 60 | } 61 | 62 | let formatter = DateFormatter() 63 | formatter.dateStyle = .medium 64 | formatter.timeStyle = .short 65 | formatter.locale = Locale.current 66 | return formatter.string(from: self) 67 | } 68 | 69 | func minutesLeft() -> Int { 70 | let timeZoneOffset = TimeZone.current.secondsFromGMT() 71 | let selfInLocalTimezone = addingTimeInterval(TimeInterval(timeZoneOffset)) 72 | let now = Date().addingTimeInterval(TimeInterval(timeZoneOffset)) 73 | return Calendar.current.dateComponents([.minute], from: now, to: selfInLocalTimezone).minute ?? -1 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/DoubleExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DoubleExtension.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-11-23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Double { 11 | var roundedWithOneDecimal: Double { 12 | var temp = self * 10 13 | temp.round() 14 | return temp / 10 15 | } 16 | 17 | var toPercent: String { 18 | let roundedPercent = roundedWithOneDecimal 19 | return roundedPercent < 0.06 ? "0%" : "\(String(format: "%.1f", roundedPercent))%" 20 | } 21 | 22 | var toKW: Double { 23 | let kiloWatt = self / 1000.0 24 | return (abs(kiloWatt) < 0.06 ? 0 : kiloWatt).roundedWithOneDecimal 25 | } 26 | 27 | var toKWString: String { 28 | let kiloWatt = self / 1000.0 29 | let roundedKilowWatt = (abs(kiloWatt) < 0.06 ? 0 : kiloWatt).roundedWithOneDecimal 30 | return roundedKilowWatt == 0 ? "\(Int(roundedKilowWatt))kW" : String(format: "%.1fkW", roundedKilowWatt) 31 | } 32 | 33 | var toFanSpeedPercentage: Double { 34 | if self == 0 { 35 | 0 36 | } else if self == 1 { 37 | 11 38 | } else { 39 | (self + 2) * 10 40 | } 41 | } 42 | 43 | var toFanSpeedTargetNumber: Double { 44 | switch self { 45 | case 11: 46 | 1 47 | case 33: 48 | 2 49 | case 44: 50 | 3 51 | case 55: 52 | 4 53 | case 66: 54 | 5 55 | case 77: 56 | 6 57 | case 88: 58 | 7 59 | case 100: 60 | 8 61 | default: 62 | 0 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/EncodableExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EncodableExtension.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-06-25. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Encodable { 11 | var dictionary: [String: Any]? { 12 | guard let data = try? JSONEncoder().encode(self) else { return nil } 13 | return (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)).flatMap { $0 as? [String: Any] } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/FontExtension.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Font { 4 | static var buttonFontExtraSmall: Font { 5 | .system(size: 8) 6 | } 7 | 8 | static var buttonFontSmall: Font { 9 | .system(size: 10) 10 | } 11 | 12 | static var buttonFontMedium: Font { 13 | .system(size: 12) 14 | } 15 | 16 | static var buttonFontLarge: Font { 17 | .system(size: 14) 18 | } 19 | 20 | static var buttonFontExtraLarge: Font { 21 | .system(size: 24) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/HomeViewModelExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeViewModelExtension.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-11-23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension HomeViewModel { 11 | var sarahIphoneimage: Image { 12 | if sarahsIphone.isActive { 13 | Image(systemImageName: .iPhoneActive) 14 | } else { 15 | Image(systemImageName: .iPhone) 16 | } 17 | } 18 | 19 | var dynamicInfoText: String { 20 | var text = "" 21 | let washerCompletionInMinutes = washerCompletionTime.date.minutesLeft() 22 | if washerCompletionInMinutes >= 0 && washerState.state.isRunning() { 23 | text.addNewLineAndAppend("Tvätten: \(timeRemainingFormatter(minutesRemaining: washerCompletionInMinutes))") 24 | } 25 | 26 | let dryerCompletionInMinutes = dryerCompletionTime.date.minutesLeft() 27 | if dryerCompletionInMinutes >= 0 && dryerState.state.isRunning() { 28 | text.addNewLineAndAppend("Torktumlaren: \(timeRemainingFormatter(minutesRemaining: dryerCompletionInMinutes))") 29 | } 30 | 31 | if let chargingPower = Double(easeeCharger.state), chargingPower > 0 { 32 | text.addNewLineAndAppend("Laddbox: \(chargingPower.roundedWithOneDecimal)kW") 33 | } 34 | 35 | if let generalWasteDescription = generalWasteDate.date.daysRemainingDescription() { 36 | text.addNewLineAndAppend("Restavfall töms \(generalWasteDescription)") 37 | } 38 | if let plasticWasteDescription = plasticWasteDate.date.daysRemainingDescription() { 39 | text.addNewLineAndAppend("Plast töms \(plasticWasteDescription)") 40 | } 41 | if let gardenWasteDescription = gardenWasteDate.date.daysRemainingDescription() { 42 | text.addNewLineAndAppend("Trädgårdsavfall töms \(gardenWasteDescription)") 43 | } 44 | 45 | return text 46 | } 47 | 48 | private func timeRemainingFormatter(minutesRemaining: Int) -> String { 49 | if minutesRemaining >= 60 { 50 | let hours = minutesRemaining / 60 51 | let minutes = minutesRemaining % 60 52 | return "\(hours)h \(minutes)min" 53 | } else { 54 | return "\(minutesRemaining)min" 55 | } 56 | } 57 | } 58 | 59 | private extension String { 60 | func isRunning() -> Bool { 61 | lowercased() != "none" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/ImageExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageExtension.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-02-19. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | extension Image { 12 | init(imageName: ImageName) { 13 | self.init(imageName.rawValue) 14 | } 15 | 16 | init(systemImageName: SystemImageName) { 17 | self.init(systemName: systemImageName.rawValue) 18 | } 19 | } 20 | 21 | enum ImageName: String { 22 | case aircondition 23 | case defrost = "defrost.filled" 24 | case evPlugType2 = "ev-plug-type2" 25 | case evPlugCCS2 = "ev-plug-ccs2" 26 | case floorplan 27 | case gym 28 | case hallway 29 | case powerGrid = "powergrid" 30 | case refresh 31 | case settings 32 | case solarPanel = "solarpanel" 33 | case seatHeater = "seatheater.filled" 34 | case vince 35 | case washing 36 | } 37 | 38 | // SF Symbols 39 | enum SystemImageName: String { 40 | case arrowDown = "arrow.down" 41 | case arrowUp = "arrow.up" 42 | case bedDouble = "bed.double" 43 | case bolt = "bolt.fill" 44 | case boltSlash = "bolt.slash" 45 | case boltCar = "bolt.car" 46 | case evCharger = "ev.charger" 47 | case evChargerSlash = "ev.charger.slash" 48 | case cctv = "video.fill" 49 | case clock 50 | case engineFilled = "engine.combustion.fill" 51 | case forkKnife = "fork.knife" 52 | case house = "house.fill" 53 | case iPhone = "iphone" 54 | case iPhoneActive = "iphone.radiowaves.left.and.right" 55 | case locked = "lock.fill" 56 | case lockSlash = "lock.slash.fill" 57 | case pause = "pause.fill" 58 | case pills = "pills.fill" 59 | case play = "play.fill" 60 | case playTV = "play.tv" 61 | case powerplug 62 | case scope 63 | case thermometer 64 | case headLightBeam = "headlight.high.beam" 65 | case lightbulbSlash = "lightbulb.slash" 66 | case trash = "trash.fill" 67 | case unknown = "questionmark.circle" 68 | case unlocked = "lock.open.fill" 69 | case xmarkCircle = "xmark.circle" 70 | } 71 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/LinearGradientExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinearGradientExtension.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2024-01-24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension LinearGradient { 11 | static var buttonGradient: LinearGradient { 12 | LinearGradient(gradient: Gradient(colors: [.appIconBlue.opacity(0.5), .appIconGreen.opacity(0.5)]), 13 | startPoint: .leading, 14 | endPoint: .trailing) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/LogExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LogExtension.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-03-11. 6 | // 7 | 8 | import Foundation 9 | import ShipBookSDK 10 | 11 | extension Log { 12 | @MainActor 13 | static var user: String { 14 | UserManager.currentUser.rawValue 15 | } 16 | 17 | static func info(_ message: String, 18 | tag: String? = nil, 19 | function: String = #function, 20 | file: String = #file, 21 | line: Int = #line) { 22 | Task { @MainActor in 23 | Log.i("\(user): \(message)", 24 | tag: tag, 25 | function: function, 26 | file: file, 27 | line: line) 28 | } 29 | } 30 | 31 | static func error(_ message: String, 32 | tag: String? = nil, 33 | function: String = #function, 34 | file: String = #file, 35 | line: Int = #line) { 36 | Task { @MainActor in 37 | Log.e("\(user): \(message)", 38 | tag: tag, 39 | function: function, 40 | file: file, 41 | line: line) 42 | } 43 | } 44 | 45 | static func warning(_ message: String, 46 | tag: String? = nil, 47 | function: String = #function, 48 | file: String = #file, 49 | line: Int = #line) { 50 | Task { @MainActor in 51 | Log.w("\(user): \(message)", 52 | tag: tag, 53 | function: function, 54 | file: file, 55 | line: line) 56 | } 57 | } 58 | 59 | static func debug(_ message: String, 60 | tag: String? = nil, 61 | function: String = #function, 62 | file: String = #file, 63 | line: Int = #line) { 64 | Task { @MainActor in 65 | Log.d("\(user): \(message)", 66 | tag: tag, 67 | function: function, 68 | file: file, 69 | line: line) 70 | } 71 | } 72 | 73 | static func verbose(_ message: String, 74 | tag: String? = nil, 75 | function: String = #function, 76 | file: String = #file, 77 | line: Int = #line) { 78 | Task { @MainActor in 79 | Log.v("\(user): \(message)", 80 | tag: tag, 81 | function: function, 82 | file: file, 83 | line: line) 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/StringExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StringExtension.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-11-22. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | var removingHTTPSchemeAndTrailingSlash: String { 12 | replacingOccurrences(of: "http://", with: "").replacingOccurrences(of: "https://", with: "").removingTrailingSlash 13 | } 14 | 15 | var removingTrailingSlash: String { 16 | if hasSuffix("/") { 17 | return String(dropLast()) 18 | } 19 | return self 20 | } 21 | 22 | var toKW: String { 23 | if let doubleValue = Double(self) { 24 | doubleValue.toKWString 25 | } else { 26 | "?kW" 27 | } 28 | } 29 | 30 | var toKWh: String { 31 | if let kiloWattHours = Double(self) { 32 | let rounded = kiloWattHours.roundedWithOneDecimal 33 | return kiloWattHours == 0 ? "\(Int(rounded))kWh" : String(format: "%.1fkWh", rounded) 34 | } else { 35 | return "?kWh" 36 | } 37 | } 38 | 39 | var toOre: String { 40 | if let doubleValue = Double(self) { 41 | let ore = Int(round(doubleValue * 100)) 42 | return "\(ore) Öre" 43 | } else { 44 | return "? Öre" 45 | } 46 | } 47 | 48 | var toKr: String { 49 | if let doubleValue = Double(self) { 50 | "\(doubleValue.roundedWithOneDecimal) Kr" 51 | } else { 52 | "? Kr" 53 | } 54 | } 55 | 56 | var roundedWithOneDecimal: Double { 57 | if let doubleValue = Double(self) { 58 | doubleValue.roundedWithOneDecimal 59 | } else { 60 | 0 61 | } 62 | } 63 | 64 | mutating func addNewLineAndAppend(_ other: String) { 65 | if isNotEmpty { 66 | append("\n") 67 | } 68 | 69 | append(other) 70 | } 71 | 72 | var removeDoubleSpaces: String { 73 | replacingOccurrences(of: " {2,}", with: " ", options: .regularExpression) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/TaskExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TaskExtension.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-10-01. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Task where Success == Never, Failure == Never { 11 | static func sleep(seconds: Double) async throws { 12 | let duration = UInt64(seconds * 1_000_000_000) 13 | try await Task.sleep(nanoseconds: duration) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/UINavigationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UINavigationController.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-05-02. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | extension UINavigationController: @retroactive UIGestureRecognizerDelegate { 12 | override open func viewDidLoad() { 13 | super.viewDidLoad() 14 | interactivePopGestureRecognizer?.delegate = self 15 | } 16 | 17 | public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 18 | viewControllers.count > 1 19 | } 20 | 21 | public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, 22 | shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { 23 | true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/UserDefaultsExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaultsExtension.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2024-01-24. 6 | // 7 | 8 | import Foundation 9 | import ShipBookSDK 10 | 11 | extension UserDefaults { 12 | @MainActor 13 | func setCoordinates(_ coordinates: Coordinates, forKey key: StorageKeys) { 14 | do { 15 | let encodedData = try JSONEncoder().encode(coordinates) 16 | set(encodedData, forKey: key.rawValue) 17 | } catch { 18 | Log.error("Failed to encode coordinates: \(error)") 19 | } 20 | } 21 | 22 | func coordinates(forKey key: String) -> Coordinates? { 23 | guard let data = data(forKey: key) else { 24 | return nil 25 | } 26 | 27 | return try? JSONDecoder().decode(Coordinates.self, from: data) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/UserDefaultsExtensionShared.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaultsExtensionShared.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2024-01-19. 6 | // 7 | 8 | import Foundation 9 | import ShipBookSDK 10 | 11 | extension UserDefaults { 12 | @MainActor 13 | static let shared: UserDefaults = { 14 | guard let sharedDefaults = UserDefaults(suiteName: "group.se.laross.intellinest.shared") else { 15 | return UserDefaults.standard 16 | } 17 | return sharedDefaults 18 | }() 19 | } 20 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/ViewExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewExtension.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2024-01-24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | func backgroundModifier() -> some View { 12 | let gradients = Gradient(stops: [.init(color: .appIconDark.opacity(0.2), location: 0), 13 | .init(color: .appIconBlue.opacity(0.4), location: 0.2), 14 | .init(color: .appIconBlue.opacity(0.5), location: 0.78), 15 | .init(color: .appIconBlue.opacity(0.5), location: 0.9), 16 | .init(color: .appIconGreen.opacity(0.4), location: 1.1)]) 17 | return background( 18 | ZStack { 19 | Color.black 20 | LinearGradient(gradient: gradients, startPoint: .top, endPoint: .bottom) 21 | } 22 | .edgesIgnoringSafeArea(.all) 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /IntelliNest/Utils/Extensions/ViewModels/HomeViewModelExtension+LockServiceProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeViewModelExtension+LockServiceProtocol.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-05-23. 6 | // 7 | 8 | import Foundation 9 | import ShipBookSDK 10 | 11 | extension HomeViewModel: LockServiceProtocol { 12 | func toggleStateForSideDoor() { 13 | guard let capturedLock = getUpdatedLock(sideDoor) else { 14 | return 15 | } 16 | Task { @MainActor in 17 | let action: Action = sideDoor.lockState == .unlocked ? .lock : .unlock 18 | sideDoor.expectedState = capturedLock.expectedState 19 | let success = await yaleApiService.setLockState(lockID: capturedLock.id, action: action) 20 | if success { 21 | sideDoor.lockState = capturedLock.expectedState 22 | } 23 | sideDoor.expectedState = .unknown 24 | } 25 | } 26 | 27 | func toggleStateForFrontDoor() { 28 | guard let capturedLock = getUpdatedLock(frontDoor) else { 29 | return 30 | } 31 | Task { @MainActor in 32 | let action: Action = frontDoor.lockState == .unlocked ? .lock : .unlock 33 | frontDoor.expectedState = capturedLock.expectedState 34 | let success = await yaleApiService.setLockState(lockID: capturedLock.id, action: action) 35 | if success { 36 | frontDoor.lockState = capturedLock.expectedState 37 | } 38 | frontDoor.expectedState = .unknown 39 | } 40 | } 41 | 42 | private func getUpdatedLock(_ lock: Lockable) -> Lockable? { 43 | var lockToUpdate = lock 44 | switch lock.lockState { 45 | case .unlocked: 46 | lockToUpdate.expectedState = .locked 47 | case .locked: 48 | lockToUpdate.expectedState = .unlocked 49 | default: 50 | Log.warning("Trying to toggle from bad initial state: \(lock.lockState)") 51 | return nil 52 | } 53 | 54 | return lockToUpdate 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /IntelliNest/Utils/SSIDUtil.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SSIDUtil.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-11-24. 6 | // 7 | 8 | @preconcurrency import CoreLocation 9 | import Foundation 10 | import NetworkExtension 11 | import ShipBookSDK 12 | import SystemConfiguration.CaptiveNetwork 13 | 14 | struct SSIDUtil { 15 | private static let locationManager = CLLocationManager() 16 | @MainActor private static let locationManagerDelegate = LocationManagerDelegate() 17 | private class LocationManagerDelegate: NSObject, CLLocationManagerDelegate { 18 | var permissionHandler: ((Bool) -> Void)? 19 | 20 | func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { 21 | switch status { 22 | case .authorizedWhenInUse, .authorizedAlways: 23 | permissionHandler?(true) 24 | default: 25 | permissionHandler?(false) 26 | } 27 | } 28 | } 29 | 30 | static func getCurrentSSID() async -> String? { 31 | let isAuthorized = await requestLocationPermission() 32 | guard isAuthorized, let currentNetwork = await NEHotspotNetwork.fetchCurrent() else { 33 | return nil 34 | } 35 | return currentNetwork.ssid 36 | } 37 | 38 | @MainActor 39 | private static func requestLocationPermission() async -> Bool { 40 | await withCheckedContinuation { continuation in 41 | locationManager.delegate = locationManagerDelegate 42 | 43 | let status = locationManager.authorizationStatus 44 | switch status { 45 | case .authorizedWhenInUse, .authorizedAlways: 46 | continuation.resume(returning: true) 47 | case .notDetermined: 48 | locationManagerDelegate.permissionHandler = { granted in 49 | continuation.resume(returning: granted) 50 | } 51 | locationManager.requestAlwaysAuthorization() 52 | default: 53 | continuation.resume(returning: false) 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /IntelliNest/ViewModels/HeatersViewModelExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeatersViewModelExtension.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-06-17. 6 | // 7 | 8 | import Foundation 9 | import ShipBookSDK 10 | 11 | extension HeatersViewModel { 12 | @MainActor 13 | func reload() async { 14 | async let tmpThermCorridor = reload(entity: thermCorridor) 15 | async let tmpThermBedroom = reload(entity: thermBedroom) 16 | async let tmpThermGym = reload(entity: thermGym) 17 | async let tmpThermVince = reload(entity: thermVince) 18 | async let tmpThermKitchen = reload(entity: thermKitchen) 19 | async let tmpThermCommonarea = reload(entity: thermCommonarea) 20 | async let tmpThermPlayroom = reload(entity: thermPlayroom) 21 | async let tmpThermGuest = reload(entity: thermGuest) 22 | async let tmpHeaterCorridor = reload(entity: heaterCorridor) 23 | async let tmpHeaterPlayroom = reload(entity: heaterPlayroom) 24 | 25 | thermCorridor = await tmpThermCorridor 26 | thermBedroom = await tmpThermBedroom 27 | thermGym = await tmpThermGym 28 | thermVince = await tmpThermVince 29 | thermKitchen = await tmpThermKitchen 30 | thermCommonarea = await tmpThermCommonarea 31 | thermPlayroom = await tmpThermPlayroom 32 | thermGuest = await tmpThermGuest 33 | heaterCorridor = await tmpHeaterCorridor 34 | heaterPlayroom = await tmpHeaterPlayroom 35 | 36 | for entityID in entityIDs { 37 | do { 38 | if entityID == .purifierFanSpeed { 39 | let purifierSpeed = try await restAPIService.reload(entityId: entityID, entityType: PurifierSpeed.self) 40 | purifier.speed = purifierSpeed.speed 41 | } else { 42 | let entity = try await restAPIService.reloadState(entityID: entityID) 43 | reload(entityID: entityID, state: entity.state) 44 | } 45 | } catch { 46 | Log.error("Failed to reload entity: \(entityID): \(error)") 47 | } 48 | } 49 | } 50 | 51 | func reload(entity: T) async -> T { 52 | await restAPIService.reload(hassEntity: entity, entityType: T.self) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /IntelliNest/ViewModels/LockServiceProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LockServiceProtocol.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-05-23. 6 | // 7 | 8 | import Foundation 9 | import ShipBookSDK 10 | 11 | @MainActor 12 | protocol LockServiceProtocol { 13 | var sideDoor: YaleLock { get set } 14 | var frontDoor: YaleLock { get set } 15 | var yaleApiService: YaleApiService { get } 16 | func toggleStateForSideDoor() 17 | func toggleStateForFrontDoor() 18 | } 19 | -------------------------------------------------------------------------------- /IntelliNest/ViewModels/VLCMediaplayerMock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VLCMediaplayerMock.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-06-10. 6 | // 7 | 8 | import Foundation 9 | 10 | class VLCMediaMock { 11 | init(url: URL) {} 12 | func addOptions(_ options: [String: Int]) {} 13 | } 14 | 15 | protocol VLCMediaPlayerMockDelegate: AnyObject {} 16 | 17 | enum VLCState { 18 | case playing 19 | } 20 | 21 | class VLCMediaPlayerMock { 22 | var drawable: Any? 23 | var state: VLCState? 24 | var delegate: VLCMediaPlayerMockDelegate? 25 | var media: VLCMediaMock? 26 | 27 | func play() {} 28 | func pause() {} 29 | } 30 | -------------------------------------------------------------------------------- /IntelliNest/Views/Components/BatteryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BatteryView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-02-09. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BatteryView: View { 11 | var level: Int 12 | var isCharging: Bool 13 | var timeUntilCharged: Int? 14 | var degreeRotation: Double 15 | var width: CGFloat 16 | var height: CGFloat 17 | private let batteryCornerRadius: CGFloat = 15 18 | 19 | init(level: Int, 20 | isCharging: Bool, 21 | timeUntilCharged: Int? = nil, 22 | degreeRotation: Double = 0, 23 | width: CGFloat = 50, 24 | height: CGFloat = 90) { 25 | self.level = level 26 | self.isCharging = isCharging 27 | self.timeUntilCharged = timeUntilCharged 28 | self.degreeRotation = degreeRotation 29 | self.width = width 30 | self.height = height 31 | } 32 | 33 | var body: some View { 34 | ZStack { 35 | Group { 36 | VStack { 37 | Rectangle() 38 | .fill(level > 99 ? Color.green : Color.gray.opacity(0.4)) 39 | .frame(width: 0.25 * width, height: 0.07 * height, alignment: .center) 40 | .padding(.bottom, -4) 41 | Rectangle() 42 | .fill(Color.gray.opacity(0.4)) 43 | .frame(width: width, height: height, alignment: .bottom) 44 | .cornerRadius(batteryCornerRadius) 45 | .padding(.top, -4) 46 | } 47 | Rectangle() 48 | .fill(level > 60 ? .green : level > 30 ? .yellow : .red) 49 | .frame(width: width, height: height, alignment: .bottom) 50 | .scaleEffect(CGSize(width: 1, height: CGFloat(level) / 100.0), anchor: .bottom) 51 | .cornerRadius(batteryCornerRadius) 52 | .padding(.bottom, -5) 53 | } 54 | .rotationEffect(.degrees(degreeRotation)) 55 | VStack { 56 | if isCharging { 57 | Image(systemName: "bolt") 58 | .foregroundColor(.yellow) 59 | if let timeUntilCharged { 60 | Text("\(timeUntilCharged)min") 61 | .font(.buttonFontSmall) 62 | .minimumScaleFactor(0.2) 63 | .lineLimit(1) 64 | } 65 | } 66 | Text("\(level)%") 67 | .font(Font.headline.weight(.semibold)) 68 | .lineLimit(1) 69 | .minimumScaleFactor(0.2) 70 | .foregroundColor(.white) 71 | } 72 | } 73 | } 74 | } 75 | 76 | struct BatteryView_Previews: PreviewProvider { 77 | static var previews: some View { 78 | HStack { 79 | BatteryView(level: 62, isCharging: false) 80 | BatteryView(level: 99, isCharging: false) 81 | BatteryView(level: 100, isCharging: false) 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /IntelliNest/Views/Components/ErrorBannerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorBannerView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2024-01-31. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ErrorBannerView: View { 11 | let title: String 12 | let message: String 13 | 14 | var body: some View { 15 | RoundedRectangle(cornerRadius: 10) 16 | .frame(height: 60) 17 | .foregroundStyle(Color.blend(.red, with: .black, ratio: 0.3)) 18 | .background(Color.clear) 19 | .shadow(radius: 5) 20 | .padding(.horizontal, 2) 21 | .overlay { 22 | VStack { 23 | Group { 24 | Text(title) 25 | .font(.buttonFontLarge) 26 | .lineLimit(1) 27 | .minimumScaleFactor(0.2) 28 | Text(message) 29 | .font(.buttonFontMedium) 30 | .lineLimit(2) 31 | .multilineTextAlignment(.center) 32 | .minimumScaleFactor(0.1) 33 | } 34 | .foregroundColor(.white) 35 | .background(Color.clear) 36 | } 37 | } 38 | } 39 | } 40 | 41 | #Preview { 42 | VStack { 43 | ErrorBannerView(title: "Failed to send bla bla", 44 | message: "Status code: 502") 45 | Spacer() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /IntelliNest/Views/Components/FuelView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct FuelView: View { 4 | var level: Int 5 | var maxLevel: Double 6 | var degreeRotation: Double 7 | var width: CGFloat 8 | var height: CGFloat 9 | 10 | private let fuelCornerRadius: CGFloat = 15 11 | 12 | init(level: Int, maxLevel: Int = 42, degreeRotation: Double = 0, width: CGFloat = 50, height: CGFloat = 90) { 13 | self.level = level 14 | self.maxLevel = Double(maxLevel) 15 | self.degreeRotation = degreeRotation 16 | self.width = width 17 | self.height = height 18 | } 19 | 20 | var body: some View { 21 | ZStack { 22 | Group { 23 | Rectangle() 24 | .fill(Color.gray.opacity(0.4)) 25 | .frame(width: width, height: height, alignment: .bottom) 26 | .cornerRadius(fuelCornerRadius) 27 | Rectangle() 28 | .fill(Double(level) > 0.6 * maxLevel ? .green : Double(level) > 0.3 * maxLevel ? .yellow : .red) 29 | .frame(width: width, height: height, alignment: .bottom) 30 | .scaleEffect(CGSize(width: 1, height: CGFloat(level) / maxLevel), anchor: .bottom) 31 | .cornerRadius(fuelCornerRadius) 32 | } 33 | .rotationEffect(.degrees(degreeRotation)) 34 | 35 | Text("\(level)L") 36 | .font(Font.headline.weight(.semibold)) 37 | .lineLimit(1) 38 | .minimumScaleFactor(0.2) 39 | .foregroundColor(.white) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /IntelliNest/Views/Components/INText.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct INText: View { 4 | let text: String 5 | let foregroundStyle: Color 6 | let font: Font 7 | let lineLimit: Int 8 | let minimumScaleFactor: CGFloat 9 | let multilineTextAlignment: TextAlignment 10 | let maxWidth: CGFloat? 11 | let maxHeight: CGFloat? 12 | let horizontalPadding: CGFloat 13 | let verticalPadding: CGFloat 14 | var trimmedText: String { 15 | text.removeDoubleSpaces 16 | } 17 | 18 | let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) 19 | var attributedText: AttributedString { 20 | (try? AttributedString(markdown: trimmedText, options: options)) ?? AttributedString(trimmedText) 21 | } 22 | 23 | var body: some View { 24 | Text(attributedText) 25 | .multilineTextAlignment(multilineTextAlignment) 26 | .minimumScaleFactor(minimumScaleFactor) 27 | .font(font) 28 | .foregroundStyle(foregroundStyle) 29 | .padding(.horizontal, horizontalPadding) 30 | .padding(.vertical, verticalPadding) 31 | .lineLimit(lineLimit) 32 | .frame(maxWidth: maxWidth, maxHeight: maxHeight) 33 | .truncationMode(.tail) 34 | } 35 | 36 | init(_ text: String, 37 | foregroundStyle: Color = .white, 38 | font: Font = .body, 39 | lineLimit: Int = .max, 40 | multilineTextAlignment: TextAlignment = .center, 41 | minimumScaleFactor: CGFloat = 0.1, 42 | maxWidth: CGFloat? = nil, 43 | maxHeight: CGFloat? = nil, 44 | horizontalPadding: CGFloat = 0, 45 | verticalPadding: CGFloat = 0) { 46 | self.text = text 47 | self.font = font 48 | self.foregroundStyle = foregroundStyle 49 | self.lineLimit = lineLimit 50 | self.multilineTextAlignment = multilineTextAlignment 51 | self.minimumScaleFactor = minimumScaleFactor 52 | self.maxWidth = maxWidth 53 | self.maxHeight = maxHeight 54 | self.horizontalPadding = horizontalPadding 55 | self.verticalPadding = verticalPadding 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /IntelliNest/Views/Components/NavigationButtonView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationButtonView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2024-01-17. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NavigationButtonView: View { 11 | @State private var tapped = false 12 | 13 | var buttonTitle: String 14 | var image: Image 15 | let buttonImageWidth: CGFloat 16 | let buttonImageHeight: CGFloat 17 | let frameSize: CGFloat 18 | let isActive: Bool 19 | let action: MainActorVoidClosure 20 | 21 | init(buttonTitle: String = "", 22 | image: Image, 23 | buttonImageWidth: CGFloat = dashboardButtonImageSize, 24 | buttonImageHeight: CGFloat = dashboardButtonImageSize, 25 | frameSize: CGFloat = dashboardButtonFrameWidth, 26 | isActive: Bool = false, 27 | action: @escaping MainActorVoidClosure = {}) { 28 | self.buttonTitle = buttonTitle 29 | self.image = image 30 | self.buttonImageWidth = buttonImageWidth 31 | self.buttonImageHeight = buttonImageHeight 32 | self.frameSize = frameSize 33 | self.isActive = isActive 34 | self.action = action 35 | } 36 | 37 | var body: some View { 38 | Button { 39 | action() 40 | withAnimation(.spring()) { 41 | tapped = true 42 | Task { @MainActor in 43 | try? await Task.sleep(seconds: 0.1) 44 | tapped = false 45 | } 46 | } 47 | } label: { 48 | VStack { 49 | image 50 | .resizable() 51 | .frame(width: buttonImageWidth, height: buttonImageHeight) 52 | .foregroundColor(isActive ? .yellow : .white) 53 | if buttonTitle.isNotEmpty { 54 | Text(buttonTitle) 55 | .font(.buttonFontMedium) 56 | .foregroundColor(.white) 57 | } 58 | } 59 | .frame(width: frameSize, height: frameSize, alignment: .center) 60 | .background(LinearGradient.buttonGradient) 61 | .cornerRadius(dashboardButtonCornerRadius) 62 | } 63 | } 64 | } 65 | 66 | #Preview { 67 | NavigationButtonView(buttonTitle: "Test", image: Image(systemName: "bolt")) 68 | } 69 | -------------------------------------------------------------------------------- /IntelliNest/Views/Components/PrimaryContentBorderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrimaryContentBorderView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2024-01-25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PrimaryContentBorderView: View { 11 | var isSelected: Bool 12 | 13 | var body: some View { 14 | RoundedRectangle(cornerRadius: dashboardButtonCornerRadius) 15 | .stroke(isSelected ? Color.primaryContentSelectedBorder : Color.primaryContentBorder, 16 | lineWidth: isSelected ? 2 : 3) 17 | } 18 | } 19 | 20 | #Preview { 21 | PrimaryContentBorderView(isSelected: false) 22 | } 23 | -------------------------------------------------------------------------------- /IntelliNest/Views/Components/VerticalDivider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerticalDivider.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-02-09. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct VerticalDivider: View { 11 | let color: Color = .gray 12 | let width: CGFloat = 2 13 | var body: some View { 14 | Rectangle() 15 | .fill(color) 16 | .frame(width: width) 17 | .edgesIgnoringSafeArea(.horizontal) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /IntelliNest/Views/Components/ZoomableImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ZoomableImageView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-06-09. 6 | // 7 | 8 | import SwiftUI 9 | import UIKit 10 | 11 | struct ZoomableImageView: View { 12 | var image: Image 13 | let isPortrait: Bool 14 | @Binding var isFullScreen: Bool 15 | @State var initialScale: CGFloat 16 | @State private var initialOffset = CGSize.zero 17 | @State private var accumulatedScale: CGFloat = 1.0 18 | @State private var latestScale: CGFloat = 1.0 19 | 20 | var body: some View { 21 | GeometryReader { geometry in 22 | Group { 23 | if isPortrait { 24 | image 25 | .resizable() 26 | .scaleEffect(accumulatedScale * latestScale) 27 | .frame(width: geometry.size.width, height: geometry.size.height) 28 | } else { 29 | image 30 | .resizable() 31 | .aspectRatio(contentMode: .fill) 32 | .scaleEffect(accumulatedScale * latestScale) 33 | .frame(width: geometry.size.width, height: geometry.size.height) 34 | .rotationEffect(.degrees(90)) 35 | } 36 | } 37 | .gesture( 38 | MagnificationGesture() 39 | .onChanged { value in 40 | latestScale = value.magnitude 41 | } 42 | .onEnded { _ in 43 | accumulatedScale *= latestScale 44 | latestScale = 1.0 45 | }, 46 | including: .all 47 | ) 48 | .onTapGesture { 49 | isFullScreen = false 50 | } 51 | .onAppear { 52 | accumulatedScale = initialScale 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /IntelliNest/Views/Dashboards/ElectricityView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ElectricityView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-12-15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ElectricityView: View { 11 | @ObservedObject var viewModel: ElectricityViewModel 12 | 13 | var body: some View { 14 | ZStack { 15 | VStack { 16 | HStack(alignment: .top) { 17 | ElectricityFlowView(viewModel: viewModel) 18 | .frame(width: 230) 19 | .padding([.top, .leading], 16) 20 | Group { 21 | Text("Kostnad idag: ") + Text("\(viewModel.tibberCostToday.state.toKr)").bold() + 22 | Text("\nKöpt idag: ") + Text("\(viewModel.pulseConsumptionToday.state.toKWh)").bold() + 23 | Text("\nMode: ") + Text("\(viewModel.sonnenBattery.operationMode.title)").bold() 24 | } 25 | .font(.buttonFontMedium) 26 | .lineLimit(3) 27 | .minimumScaleFactor(0.1) 28 | .padding(.top, 8) 29 | Spacer() 30 | } 31 | Spacer() 32 | NordPoolHistoryView(nordPool: $viewModel.nordPool) 33 | .frame(height: 350) 34 | .padding(.bottom, 16) 35 | .padding(.horizontal, 8) 36 | } 37 | 38 | if viewModel.isShowingSonnenSettings { 39 | SonnenSettingsView(viewModel: viewModel) 40 | } 41 | } 42 | .foregroundStyle(.white) 43 | } 44 | } 45 | 46 | #Preview { 47 | VStack { 48 | ElectricityView(viewModel: PreviewProviderUtil.electricityViewModel) 49 | .backgroundModifier() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /IntelliNest/Views/Dashboards/HeatersView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeatersView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-02-02. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HeatersView: View { 11 | @ObservedObject var viewModel: HeatersViewModel 12 | 13 | var body: some View { 14 | ScrollView { 15 | VStack { 16 | SimpleHeaterView(roomName: "Korridoren", 17 | therm1: viewModel.thermCorridor, 18 | therm2: viewModel.thermBedroom, 19 | therm3: viewModel.thermVince, 20 | therm4: viewModel.thermGym, 21 | heater: $viewModel.heaterCorridor, 22 | resetClimateTimeEntity: $viewModel.resetCorridorHeaterTime, 23 | isTimerModeEnabled: viewModel.heaterCorridorTimerMode.isActive, 24 | showDetailsClosure: viewModel.showHeaterDetails, 25 | setTargetTemperatureClosure: viewModel.setTargetTemperature, 26 | setHvacModeClosure: viewModel.setHvacMode, 27 | toggleTimerModeClosure: viewModel.toggleCorridorTimerMode, 28 | setClimateScheduleTimeClosure: viewModel.setClimateSchedule) 29 | .padding(.top) 30 | Divider() 31 | SimpleHeaterView(roomName: "Lekrummet", 32 | therm1: viewModel.thermPlayroom, 33 | therm2: viewModel.thermCommonarea, 34 | therm3: viewModel.thermGuest, 35 | therm4: viewModel.thermKitchen, 36 | heater: $viewModel.heaterPlayroom, 37 | resetClimateTimeEntity: $viewModel.resetPlayroomHeaterTime, 38 | isTimerModeEnabled: viewModel.heaterPlayroomTimerMode.isActive, 39 | showDetailsClosure: viewModel.showHeaterDetails, 40 | setTargetTemperatureClosure: viewModel.setTargetTemperature, 41 | setHvacModeClosure: viewModel.setHvacMode, 42 | toggleTimerModeClosure: viewModel.togglePlayroomTimerMode, 43 | setClimateScheduleTimeClosure: viewModel.setClimateSchedule) 44 | .padding(.bottom) 45 | Divider() 46 | PurifierView(purifier: $viewModel.purifier, 47 | resetClimateTimeEntity: $viewModel.resetPurifierTime, 48 | isTimerModeEnabled: viewModel.purifierTimerMode.isActive, 49 | setFanSpeedClosure: viewModel.setPurifierFanSpeed, 50 | toggleTimerModeClosure: viewModel.togglePurifierTimerMode, 51 | setClimateScheduleTimeClosure: viewModel.setClimateSchedule) 52 | .padding(.bottom) 53 | } 54 | } 55 | } 56 | } 57 | 58 | struct HeatersView_Previews: PreviewProvider { 59 | static var previews: some View { 60 | HeatersView(viewModel: HeatersViewModel(restAPIService: PreviewProviderUtil.restAPIService, 61 | showHeaterDetails: { _ in })) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /IntelliNest/Views/Dashboards/LeafView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct LeafView: View { 4 | @ObservedObject var viewModel: LynkViewModel 5 | @State var isEngineAlertVisible = false 6 | 7 | var body: some View { 8 | VStack { 9 | INText("Leaf", font: .title3) 10 | INText(viewModel.leafLastPoll.date.humanReadable, font: .buttonFontExtraSmall) 11 | .padding(.bottom) 12 | HStack { 13 | Spacer() 14 | VStack { 15 | BatteryView(level: Int(viewModel.leafBattery.inputNumber.rounded()), 16 | isCharging: viewModel.isLeafCharging.isActive, 17 | degreeRotation: 90, 18 | width: 50, 19 | height: 90) 20 | INText("\(viewModel.leafRangeAC.state)km", font: .buttonFontSmall) 21 | .padding(.top, -32) 22 | } 23 | .padding(.trailing, 32) 24 | 25 | VStack { 26 | ServiceButtonView(buttonTitle: viewModel.leafClimateTitle, 27 | isActive: viewModel.isLeafAirConditionActive, 28 | buttonSize: viewModel.buttonSize, 29 | icon: .init(systemImageName: .thermometer), 30 | iconWidth: 25, 31 | iconHeight: 35, 32 | isLoading: viewModel.isLeafAirConditionLoading, 33 | action: viewModel.toggleLeafClimate) 34 | .contextMenu { 35 | Button(action: viewModel.startLeafClimate) { 36 | INText("Starta") 37 | } 38 | Button(action: viewModel.stopLeafClimate) { 39 | INText("Stoppa") 40 | } 41 | } 42 | if let minutes = viewModel.leafClimateTimerRemaining { 43 | INText("\(minutes)min") 44 | } 45 | } 46 | Spacer() 47 | } 48 | Spacer() 49 | } 50 | } 51 | } 52 | 53 | struct Leaf_Previews: PreviewProvider { 54 | static var previews: some View { 55 | LeafView(viewModel: PreviewProviderUtil.lynkViewModel) 56 | .backgroundModifier() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /IntelliNest/Views/Electricity/FlowIndicatorView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct FlowIndicatorView: View { 4 | @Binding var isFlowing: Bool 5 | var flowIntensity: Double 6 | let arrowCount: Int 7 | 8 | var body: some View { 9 | if isFlowing { 10 | TimelineView(.animation(minimumInterval: nil)) { timeline in 11 | let date = timeline.date.timeIntervalSinceReferenceDate 12 | let phase = date * flowIntensity 13 | 14 | HStack { 15 | ForEach(0 ..< arrowCount, id: \.self) { index in 16 | ArrowShape() 17 | .frame(width: 12, height: 6) 18 | .foregroundStyle(.yellow) 19 | .opacity(arrowOpacity(phase: phase, index: index)) 20 | } 21 | } 22 | } 23 | } 24 | } 25 | 26 | private func arrowOpacity(phase: Double, index: Int) -> Double { 27 | guard arrowCount > 0 else { return 0 } 28 | let position = (phase - Double(index) / Double(arrowCount)).truncatingRemainder(dividingBy: 1.0) 29 | return max(0, sin(position * 2 * .pi)) 30 | } 31 | } 32 | 33 | private struct ArrowShape: Shape { 34 | func path(in rect: CGRect) -> Path { 35 | var path = Path() 36 | path.move(to: .zero) 37 | path.addLine(to: CGPoint(x: rect.width, y: rect.height / 2)) 38 | path.addLine(to: CGPoint(x: 0, y: rect.height)) 39 | path.addLine(to: CGPoint(x: rect.width / 4, y: rect.height / 2)) 40 | path.closeSubpath() 41 | return path 42 | } 43 | } 44 | 45 | #Preview { 46 | FlowIndicatorView(isFlowing: .constant(true), flowIntensity: 1, arrowCount: 3) 47 | } 48 | -------------------------------------------------------------------------------- /IntelliNest/Views/Heater/DetailedHeaterView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailedHeaterView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-06-22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum HeaterType { 11 | case corridor 12 | case playroom 13 | } 14 | 15 | @MainActor 16 | struct DetailedHeaterView: View { 17 | @ObservedObject var viewModel: HeatersViewModel 18 | var selectedHeater: HeaterType 19 | 20 | var heater: HeaterEntity { 21 | selectedHeater == .corridor ? viewModel.heaterCorridor : viewModel.heaterPlayroom 22 | } 23 | 24 | var body: some View { 25 | VStack { 26 | Text("Fläkt") 27 | .font(.title2) 28 | .foregroundColor(.white) 29 | FanModeView(fanMode: heater.fanMode, 30 | fanModeSelectedCallback: { fanMode in 31 | viewModel.setFanMode(heater, fanMode) 32 | }) 33 | .padding(.bottom) 34 | 35 | Text("Horisontellt läge") 36 | .font(.title2) 37 | .foregroundColor(.white) 38 | HorizontalModeView(mode: heater.vaneHorizontal, 39 | leftVaneTitle: heater.leftVaneTitle, 40 | rightVaneTitle: heater.rightVaneTitle, 41 | horizontalModeSelectedCallback: { horizontalMode in 42 | viewModel.horizontalModeSelectedCallback(heater, horizontalMode) 43 | }) 44 | .padding(.bottom) 45 | 46 | Text("Vertikalt läge") 47 | .font(.title2) 48 | .foregroundColor(.white) 49 | VerticalPositionView(mode: heater.vaneVertical, 50 | verticalModeSelectedCallback: { verticalMode in 51 | viewModel.verticalModeSelectedCallback(heater, verticalMode) 52 | }) 53 | Spacer() 54 | } 55 | .padding() 56 | .backgroundModifier() 57 | } 58 | } 59 | 60 | struct DetailedHeaterView_Previews: PreviewProvider { 61 | static var previews: some View { 62 | DetailedHeaterView(viewModel: PreviewProviderUtil.heatersViewModel, selectedHeater: .corridor) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /IntelliNest/Views/Heater/FanModeButtonView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FanModeButtonView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-11-10. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FanModeButtonView: View { 11 | let fanButtonSize: CGFloat = 50 12 | let fanButtonCornerRadius: CGFloat = 10 13 | let imageSize: CGFloat = 15 14 | var isSelectedFanMode: Bool { 15 | fanMode == selectedFanMode 16 | } 17 | 18 | var fanMode: HeaterFanMode 19 | var selectedFanMode: HeaterFanMode 20 | var image: Image? 21 | var fanModeSelectedCallback: FanModeClosure 22 | 23 | var body: some View { 24 | Button { 25 | fanModeSelectedCallback(fanMode) 26 | } label: { 27 | Group { 28 | if let image { 29 | VStack { 30 | image 31 | .resizable() 32 | .frame(width: imageSize, height: imageSize, alignment: .center) 33 | .colorMultiply(isSelectedFanMode ? .yellow : .white) 34 | .padding(.top) 35 | Text(fanMode.rawValue.capitalized) 36 | .font(.buttonFontLarge.bold()) 37 | .padding(.bottom) 38 | } 39 | } else { 40 | Text(fanMode.rawValue) 41 | .font(isSelectedFanMode ? .buttonFontExtraLarge.bold() : .buttonFontExtraLarge) 42 | } 43 | } 44 | .frame(width: fanButtonSize, height: fanButtonSize, alignment: .center) 45 | .foregroundStyle(isSelectedFanMode ? .yellow : .white) 46 | .overlay { 47 | PrimaryContentBorderView(isSelected: isSelectedFanMode) 48 | } 49 | } 50 | } 51 | } 52 | 53 | #Preview { 54 | HStack { 55 | FanModeButtonView(fanMode: .auto, selectedFanMode: .one, image: .init(imageName: .refresh), fanModeSelectedCallback: { _ in }) 56 | FanModeButtonView(fanMode: .auto, selectedFanMode: .auto, image: .init(imageName: .refresh), fanModeSelectedCallback: { _ in }) 57 | FanModeButtonView(fanMode: .one, selectedFanMode: .one, fanModeSelectedCallback: { _ in }) 58 | FanModeButtonView(fanMode: .two, selectedFanMode: .one, fanModeSelectedCallback: { _ in }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /IntelliNest/Views/Heater/FanModeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FanModeView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-06-22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FanModeView: View { 11 | var fanMode: HeaterFanMode 12 | var fanModeSelectedCallback: FanModeClosure 13 | 14 | var body: some View { 15 | HStack { 16 | FanModeButtonView(fanMode: .auto, 17 | selectedFanMode: fanMode, 18 | image: .init(imageName: .refresh), 19 | fanModeSelectedCallback: fanModeSelectedCallback) 20 | FanModeButtonView(fanMode: .one, selectedFanMode: fanMode, fanModeSelectedCallback: fanModeSelectedCallback) 21 | FanModeButtonView(fanMode: .two, selectedFanMode: fanMode, fanModeSelectedCallback: fanModeSelectedCallback) 22 | FanModeButtonView(fanMode: .three, selectedFanMode: fanMode, fanModeSelectedCallback: fanModeSelectedCallback) 23 | FanModeButtonView(fanMode: .four, selectedFanMode: fanMode, fanModeSelectedCallback: fanModeSelectedCallback) 24 | FanModeButtonView(fanMode: .five, selectedFanMode: fanMode, fanModeSelectedCallback: fanModeSelectedCallback) 25 | } 26 | } 27 | } 28 | 29 | struct FanModeView_Previews: PreviewProvider { 30 | static var previews: some View { 31 | FanModeView(fanMode: .auto, fanModeSelectedCallback: { _ in }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /IntelliNest/Views/Heater/HorizontalButtonView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HorizontalButtonView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-11-10. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HorizontalButtonView: View { 11 | let horizontalButtonWidth: CGFloat = 50 12 | let horizontalButtonHeight: CGFloat = 50 13 | let horizontalButtonCornerRadius: CGFloat = 10 14 | var isSelectedMode: Bool { 15 | mode == selectedMode 16 | } 17 | 18 | let mode: HeaterHorizontalMode 19 | let selectedMode: HeaterHorizontalMode 20 | let buttonTitle: String? 21 | let buttonImageName: String? 22 | var horizontalModeSelectedCallback: HorizontalModeClosure 23 | 24 | var body: some View { 25 | Button { 26 | horizontalModeSelectedCallback(mode) 27 | } label: { 28 | Group { 29 | if let buttonTitle { 30 | if buttonTitle.count > 5 { 31 | Text(buttonTitle) 32 | .font(.caption) 33 | .frame(width: 100, height: horizontalButtonHeight, alignment: .center) 34 | } else if buttonTitle.count > 1 { 35 | Text(buttonTitle).font(.body) 36 | .frame(width: 60, height: horizontalButtonHeight, alignment: .center) 37 | } else { 38 | Text(buttonTitle).font(.title) 39 | .frame(width: horizontalButtonWidth, height: horizontalButtonHeight, alignment: .center) 40 | } 41 | } 42 | 43 | if let buttonImageName { 44 | Image(systemName: buttonImageName) 45 | .frame(width: 50, height: 50, alignment: .center) 46 | } 47 | } 48 | .foregroundColor(isSelectedMode ? .yellow : .white) 49 | .overlay { 50 | PrimaryContentBorderView(isSelected: isSelectedMode) 51 | } 52 | } 53 | } 54 | 55 | init(mode: HeaterHorizontalMode, 56 | selectedMode: HeaterHorizontalMode, 57 | buttonTitle: String? = nil, 58 | buttonImageName: String? = nil, 59 | horizontalModeSelectedCallback: @escaping HorizontalModeClosure) { 60 | self.mode = mode 61 | self.selectedMode = selectedMode 62 | self.buttonTitle = buttonTitle 63 | self.buttonImageName = buttonImageName 64 | self.horizontalModeSelectedCallback = horizontalModeSelectedCallback 65 | } 66 | } 67 | 68 | #Preview { 69 | HorizontalButtonView(mode: .auto, 70 | selectedMode: .auto, 71 | buttonTitle: "Vardagsrummet", 72 | buttonImageName: nil, 73 | horizontalModeSelectedCallback: { _ in }) 74 | } 75 | -------------------------------------------------------------------------------- /IntelliNest/Views/Heater/HorizontalModeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HorizontalModeView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-06-22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HorizontalModeView: View { 11 | let mode: HeaterHorizontalMode 12 | let leftVaneTitle: String 13 | let rightVaneTitle: String 14 | var horizontalModeSelectedCallback: HorizontalModeClosure 15 | 16 | var body: some View { 17 | VStack { 18 | HStack { 19 | HorizontalButtonView(mode: .oneLeft, 20 | selectedMode: mode, 21 | buttonTitle: leftVaneTitle, 22 | horizontalModeSelectedCallback: horizontalModeSelectedCallback) 23 | HorizontalButtonView(mode: .two, 24 | selectedMode: mode, 25 | buttonImageName: "arrow.down.backward", 26 | horizontalModeSelectedCallback: horizontalModeSelectedCallback) 27 | HorizontalButtonView(mode: .three, 28 | selectedMode: mode, 29 | buttonImageName: "arrow.down", 30 | horizontalModeSelectedCallback: horizontalModeSelectedCallback) 31 | HorizontalButtonView(mode: .four, 32 | selectedMode: mode, 33 | buttonImageName: "arrow.down.forward", 34 | horizontalModeSelectedCallback: horizontalModeSelectedCallback) 35 | HorizontalButtonView(mode: .fiveRight, 36 | selectedMode: mode, 37 | buttonTitle: rightVaneTitle, 38 | horizontalModeSelectedCallback: horizontalModeSelectedCallback) 39 | } 40 | .padding([.top, .leading, .trailing]) 41 | HStack { 42 | HorizontalButtonView(mode: .auto, 43 | selectedMode: mode, 44 | buttonTitle: "Auto", 45 | horizontalModeSelectedCallback: horizontalModeSelectedCallback) 46 | HorizontalButtonView(mode: .split, 47 | selectedMode: mode, 48 | buttonTitle: "Split", 49 | horizontalModeSelectedCallback: horizontalModeSelectedCallback) 50 | HorizontalButtonView(mode: .swing, 51 | selectedMode: mode, 52 | buttonTitle: "Swing", 53 | horizontalModeSelectedCallback: horizontalModeSelectedCallback) 54 | } 55 | .padding(.bottom) 56 | } 57 | } 58 | } 59 | 60 | struct HorizontalModeView_Previews: PreviewProvider { 61 | static var previews: some View { 62 | HorizontalModeView(mode: .swing, leftVaneTitle: "Left", rightVaneTitle: "Right", 63 | horizontalModeSelectedCallback: { _ in }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /IntelliNest/Views/Heater/HvacModeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HvacModeView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-02-19. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HvacModeView: View { 11 | var heater: HeaterEntity 12 | let mode: HvacMode 13 | let imageSize: CGFloat = 20 14 | var hvacModeSelectedCallback: HeaterStringClosure 15 | 16 | var body: some View { 17 | HStack { 18 | Button { 19 | hvacModeSelectedCallback(heater, .off) 20 | } label: { 21 | HvacButtonLabel(hvacButton: AnyView( 22 | VStack { 23 | Image(systemName: "bolt.slash.fill").resizable() 24 | .frame(width: imageSize, height: imageSize, alignment: .center) 25 | Text("Av") 26 | } 27 | ), isSelectedMode: mode == .off) 28 | } 29 | Button { 30 | hvacModeSelectedCallback(heater, .heat) 31 | } label: { 32 | HvacButtonLabel(hvacButton: AnyView( 33 | VStack { 34 | Image(systemName: "flame").resizable() 35 | .frame(width: imageSize, height: imageSize, alignment: .center) 36 | Text("Värme") 37 | } 38 | ), isSelectedMode: mode == .heat) 39 | } 40 | Button { 41 | hvacModeSelectedCallback(heater, .cool) 42 | } label: { 43 | HvacButtonLabel(hvacButton: AnyView( 44 | VStack { 45 | Image(systemName: "snowflake").resizable() 46 | .frame(width: imageSize, height: imageSize, alignment: .center) 47 | Text("Kyla") 48 | } 49 | ), isSelectedMode: mode == .cool) 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /IntelliNest/Views/Heater/NumberTextView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NumberTextView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-06-22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NumberTextView: View { 11 | var pickerTextWidth: CGFloat 12 | @Binding var targetTemperature: Double 13 | @Binding var selectedNewTarget: Bool 14 | var index: Double 15 | let numberPickerFormat: NumberFormatter 16 | var body: some View { 17 | Text("\(index as NSNumber, formatter: numberPickerFormat)") 18 | .id(index) 19 | .font(targetTemperature == index ? .title : .body) 20 | .frame(width: pickerTextWidth, height: 30) 21 | .gesture(TapGesture().onEnded { 22 | selectedNewTarget = true 23 | targetTemperature = index 24 | }) 25 | .foregroundColor(targetTemperature == index ? .white : .white.opacity(0.667)) 26 | } 27 | } 28 | 29 | struct NumberTextView_Previews: PreviewProvider { 30 | static var previews: some View { 31 | NumberTextView(pickerTextWidth: 20, targetTemperature: .constant(22), selectedNewTarget: .constant(false), 32 | index: 10, numberPickerFormat: NumberFormatter()) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /IntelliNest/Views/Heater/ThermometerGroupView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThermometerGroupView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-06-22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ThermometerGroupView: View { 11 | var therm1: Entity 12 | var therm2: Entity 13 | var therm3: Entity 14 | var therm4: Entity 15 | 16 | var body: some View { 17 | HStack { 18 | ThermometerView(thermometer: therm1) 19 | ThermometerView(thermometer: therm2) 20 | .padding(.leading) 21 | ThermometerView(thermometer: therm3) 22 | .padding(.horizontal) 23 | ThermometerView(thermometer: therm4) 24 | } 25 | } 26 | } 27 | 28 | struct ThermometerGroupView_Previews: PreviewProvider { 29 | static var previews: some View { 30 | let therm = Entity(entityId: EntityId.thermGym) 31 | ThermometerGroupView(therm1: therm, therm2: therm, therm3: therm, therm4: therm) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /IntelliNest/Views/Heater/ThermometerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThermometerView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-06-22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ThermometerView: View { 11 | var thermometer: Entity 12 | var body: some View { 13 | VStack { 14 | Text(thermometer.entityId.roomTitle) 15 | .font(.caption2) 16 | .foregroundColor(.white) 17 | .lineLimit(1) 18 | .minimumScaleFactor(0.2) 19 | Text(thermometer.state == "unavailable" ? "?" : 20 | "\(thermometer.state.replacingOccurrences(of: ".", with: ",")) ℃") 21 | .foregroundColor(.white) 22 | .lineLimit(1) 23 | .minimumScaleFactor(0.01) 24 | } 25 | } 26 | } 27 | 28 | private extension EntityId { 29 | var roomTitle: String { 30 | switch self { 31 | case .thermCorridor: 32 | "Korridoren" 33 | case .thermBedroom: 34 | "Sovrummet" 35 | case .thermVince: 36 | "Vince rum" 37 | case .thermGym: 38 | "Gymmet" 39 | case .thermPlayroom: 40 | "Lekrummet" 41 | case .thermCommonarea: 42 | "Vardagsrummet" 43 | case .thermGuest: 44 | "Gästrummet" 45 | case .thermKitchen: 46 | "Köket" 47 | default: 48 | "Missing room" 49 | } 50 | } 51 | } 52 | 53 | struct ThermometerView_Previews: PreviewProvider { 54 | static var previews: some View { 55 | let therm = Entity(entityId: EntityId.thermGym) 56 | ThermometerView(thermometer: therm) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /IntelliNest/Views/Heater/VerticalButtonView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerticalButtonView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-11-10. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct VerticalButtonView: View { 11 | let verticalButtonWidth: CGFloat = 50 12 | let verticalButtonHeight: CGFloat = 50 13 | let verticalButtonCornerRadius: CGFloat = 10 14 | var isSelectedMode: Bool { 15 | mode == selectedMode 16 | } 17 | 18 | let mode: HeaterVerticalMode 19 | let selectedMode: HeaterVerticalMode 20 | var verticalModeSelectedCallback: VerticalModeClosure 21 | 22 | var body: some View { 23 | Button { 24 | verticalModeSelectedCallback(mode) 25 | } label: { 26 | Group { 27 | if let buttonTitle = mode.buttonTitle { 28 | if buttonTitle.count > 1 { 29 | Text(buttonTitle).font(.body) 30 | .frame(width: verticalButtonWidth, height: verticalButtonHeight, alignment: .center) 31 | .padding(.horizontal, 5) 32 | } else { 33 | Text(buttonTitle).font(.title) 34 | .frame(width: verticalButtonWidth, height: verticalButtonHeight, alignment: .center) 35 | } 36 | } 37 | 38 | if let buttonImageName = mode.buttonImageName { 39 | Image(systemName: buttonImageName) 40 | .frame(width: 50, height: 50, alignment: .center) 41 | } 42 | } 43 | .foregroundColor(isSelectedMode ? .yellow : .white) 44 | .overlay { 45 | PrimaryContentBorderView(isSelected: isSelectedMode) 46 | } 47 | } 48 | } 49 | } 50 | 51 | #Preview { 52 | VerticalButtonView(mode: .auto, selectedMode: .highest, verticalModeSelectedCallback: { _ in }) 53 | } 54 | 55 | private extension HeaterVerticalMode { 56 | var buttonTitle: String? { 57 | switch self { 58 | case .auto: 59 | "Auto" 60 | case .highest: 61 | "Upp" 62 | case .lowest: 63 | "Ner" 64 | case .swing: 65 | "Swing" 66 | default: 67 | nil 68 | } 69 | } 70 | 71 | var buttonImageName: String? { 72 | switch self { 73 | case .position2: 74 | "arrow.up.forward" 75 | case .position3: 76 | "arrow.right" 77 | case .position4: 78 | "arrow.down.forward" 79 | default: 80 | nil 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /IntelliNest/Views/Heater/VerticalPositionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerticalPositionView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-06-22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct VerticalPositionView: View { 11 | let mode: HeaterVerticalMode 12 | var verticalModeSelectedCallback: VerticalModeClosure 13 | 14 | var body: some View { 15 | HStack { 16 | VStack { 17 | VerticalButtonView(mode: .highest, selectedMode: mode, verticalModeSelectedCallback: verticalModeSelectedCallback) 18 | VerticalButtonView(mode: .position2, selectedMode: mode, verticalModeSelectedCallback: verticalModeSelectedCallback) 19 | VerticalButtonView(mode: .position3, selectedMode: mode, verticalModeSelectedCallback: verticalModeSelectedCallback) 20 | VerticalButtonView(mode: .position4, selectedMode: mode, verticalModeSelectedCallback: verticalModeSelectedCallback) 21 | VerticalButtonView(mode: .lowest, selectedMode: mode, verticalModeSelectedCallback: verticalModeSelectedCallback) 22 | } 23 | .padding([.top, .leading, .trailing]) 24 | VStack { 25 | VerticalButtonView(mode: .auto, selectedMode: mode, verticalModeSelectedCallback: verticalModeSelectedCallback) 26 | VerticalButtonView(mode: .swing, selectedMode: mode, verticalModeSelectedCallback: verticalModeSelectedCallback) 27 | } 28 | .padding(.bottom) 29 | } 30 | } 31 | } 32 | 33 | struct HeaterVerticalPositionView_Previews: PreviewProvider { 34 | static var previews: some View { 35 | VerticalPositionView(mode: .lowest, verticalModeSelectedCallback: { _ in }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /IntelliNest/Views/Home/CoffeeMachineSchedulingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoffeeMachineSchedulingView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-10-19. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CoffeeMachineSchedulingView: View { 11 | private var dateProxy: Binding { 12 | Binding(get: { 13 | coffeeMachineStartTime.date 14 | }, set: { 15 | coffeeMachineStartTime.date = $0 16 | setCoffeeMachineStartTime(coffeeMachineStartTime) 17 | }) 18 | } 19 | 20 | @Binding var isVisible: Bool 21 | let title: String 22 | @Binding var coffeeMachineStartTime: Entity 23 | var coffeeMachineStartTimeEnabled: Entity 24 | let setCoffeeMachineStartTime: EntityClosure 25 | let toggleStartTimeEnabledAction: VoidClosure 26 | 27 | var body: some View { 28 | ZStack { 29 | FullScreenBackgroundOverlay() 30 | .onTapGesture { 31 | isVisible = false 32 | } 33 | RoundedRectangle(cornerRadius: 20) 34 | .foregroundColor(.black) 35 | .frame(width: 220, height: 150) 36 | VStack(spacing: 16) { 37 | Text(title) 38 | .font(.title2) 39 | .foregroundStyle(.white) 40 | HStack(spacing: 16) { 41 | DatePicker("", selection: dateProxy, displayedComponents: .hourAndMinute) 42 | .labelsHidden() 43 | .colorScheme(.dark) 44 | Button { 45 | toggleStartTimeEnabledAction() 46 | } label: { 47 | Image(systemImageName: .clock) 48 | .resizable() 49 | .frame(width: 40, height: 40) 50 | .foregroundColor(coffeeMachineStartTimeEnabled.isActive ? .lightBlue : .white) 51 | } 52 | } 53 | } 54 | } 55 | .frame(maxWidth: .infinity, maxHeight: .infinity) 56 | } 57 | } 58 | 59 | #Preview { 60 | CoffeeMachineSchedulingView(isVisible: .constant(true), 61 | title: "Kaffemaskinen", 62 | coffeeMachineStartTime: .constant(.init(entityId: .coffeeMachineStartTime)), 63 | coffeeMachineStartTimeEnabled: .init(entityId: .coffeeMachineStartTimeEnabled), 64 | setCoffeeMachineStartTime: { _ in }, 65 | toggleStartTimeEnabledAction: {}) 66 | } 67 | -------------------------------------------------------------------------------- /IntelliNest/Views/Labels/HeaterGroupLabels.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HeaterGroupLabels.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-02-19. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HvacButtonLabel: View { 11 | var hvacButton: AnyView 12 | var isSelectedMode: Bool 13 | let hvacButtonSize: CGFloat = 60 14 | let hvacButtonCornerRadius: CGFloat = 15 15 | var body: some View { 16 | Group { 17 | hvacButton 18 | } 19 | .frame(width: hvacButtonSize, height: hvacButtonSize, alignment: .center) 20 | .foregroundColor(isSelectedMode ? .yellow : .white) 21 | .font(isSelectedMode ? .buttonFontLarge.bold() : .buttonFontMedium) 22 | .overlay { 23 | PrimaryContentBorderView(isSelected: isSelectedMode) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /IntelliNest/Views/Light/BulbButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BulbButton.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-08-18. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BulbButton: View { 11 | private let buttonImageSIze: CGFloat = 24 12 | 13 | var light: LightEntity 14 | let onTapAction: AsyncLightClosure 15 | 16 | var body: some View { 17 | Button { 18 | Task { @MainActor in 19 | await onTapAction(light) 20 | } 21 | } label: { 22 | if light.isActive { 23 | Image(systemName: "lightbulb.fill") 24 | .font(.system(size: buttonImageSIze)) 25 | .foregroundColor(.yellow) 26 | } else { 27 | Image(systemName: "lightbulb.slash") 28 | .font(.system(size: buttonImageSIze)) 29 | } 30 | } 31 | .padding(.vertical, 3.0) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /IntelliNest/Views/Light/DualBulbRoomView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DualBulbRoomView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-07-07. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DualBulbRoomView: View { 11 | let roomName: String 12 | @Binding var lightGroup: LightEntity 13 | @Binding var light1: LightEntity 14 | @Binding var light2: LightEntity 15 | let light1Name: String 16 | let light2Name: String 17 | let onTapAction: AsyncSlideableClosure 18 | let onSliderChangeAction: SlideableIntClosure 19 | let onSliderReleaseAction: AsyncSlideableClosure 20 | let sliderWidth: CGFloat 21 | let sliderHeight: CGFloat 22 | let bulbTitleSize: CGFloat 23 | let roomTitleSize: CGFloat 24 | 25 | var body: some View { 26 | VStack { 27 | VStack { 28 | Text(roomName) 29 | .font(.system(size: roomTitleSize)) 30 | .foregroundColor(.white) 31 | BulbButton(light: lightGroup, onTapAction: onTapAction) 32 | } 33 | 34 | HStack { 35 | VStack { 36 | VerticalSlider(slideable: light1, 37 | onSliderChangeAction: onSliderChangeAction, 38 | onSliderReleaseAction: onSliderReleaseAction, 39 | onTapAction: onTapAction) 40 | .frame(width: sliderWidth, height: sliderHeight, alignment: .center) 41 | Text(light1Name) 42 | .font(.system(size: bulbTitleSize)) 43 | .foregroundColor(.white) 44 | } 45 | 46 | VStack { 47 | VerticalSlider(slideable: light2, 48 | onSliderChangeAction: onSliderChangeAction, 49 | onSliderReleaseAction: onSliderReleaseAction, 50 | onTapAction: onTapAction) 51 | .frame(width: sliderWidth, height: sliderHeight, alignment: .center) 52 | Text(light2Name) 53 | .font(.system(size: bulbTitleSize)) 54 | .foregroundColor(.white) 55 | } 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /IntelliNest/Views/Light/SingleRoomLight.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SingleRoomLight.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-07-13. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SingleRoomLight: View { 11 | let roomName: String 12 | @Binding var light: LightEntity 13 | let onTapAction: AsyncSlideableClosure 14 | let onSliderChangeAction: SlideableIntClosure 15 | let onSliderReleaseAction: AsyncSlideableClosure 16 | let roomTitleSize: CGFloat 17 | let sliderWidth: CGFloat 18 | let sliderHeight: CGFloat 19 | 20 | var body: some View { 21 | VStack { 22 | VStack { 23 | Text(roomName) 24 | .font(.system(size: roomTitleSize)) 25 | .foregroundColor(.white) 26 | BulbButton(light: light, onTapAction: onTapAction) 27 | } 28 | 29 | HStack { 30 | VerticalSlider(slideable: light, 31 | onSliderChangeAction: onSliderChangeAction, 32 | onSliderReleaseAction: onSliderReleaseAction, 33 | onTapAction: onTapAction) 34 | .frame(width: sliderWidth, height: sliderHeight, alignment: .center) 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /IntelliNest/Views/Lynk/DateTimePickerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DateTimePickerView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-06-14. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DateTimePickerView: View { 11 | let title: String 12 | let displayComponents: DatePickerComponents 13 | @Binding var dateTime: Entity 14 | @Binding var dateTimeEnabled: Entity 15 | @State var dateTimeLoaded = false 16 | let updateToggle: AsyncEntityClosure 17 | let setDateTimeClosure: EntityClosure 18 | 19 | var body: some View { 20 | HStack { 21 | Text(title) 22 | Spacer() 23 | .frame(width: 20) 24 | DatePicker("", selection: $dateTime.date, displayedComponents: displayComponents) 25 | .labelsHidden() 26 | .onChange(of: dateTime.date) { 27 | if dateTimeLoaded { 28 | dateTimeEnabled.isActive = true 29 | setDateTimeClosure(dateTime) 30 | } else { 31 | dateTimeLoaded = true 32 | } 33 | } 34 | Toggle("", isOn: $dateTimeEnabled.isActive) 35 | .onChange(of: dateTimeEnabled.isActive) { 36 | Task { 37 | await updateToggle(dateTimeEnabled) 38 | } 39 | } 40 | } 41 | .padding([.horizontal, .bottom], 8) 42 | } 43 | } 44 | 45 | struct DatePickerView_Previews: PreviewProvider { 46 | static var previews: some View { 47 | DateTimePickerView(title: "Title", 48 | displayComponents: .hourAndMinute, 49 | dateTime: .constant(.init(entityId: .heaterCorridor)), 50 | dateTimeEnabled: .constant(.init(entityId: .heaterCorridor)), 51 | updateToggle: { _ in }, 52 | setDateTimeClosure: { _ in }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /IntelliNest/Views/Lynk/LimitPickerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LimitPickerView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2023-02-24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LimitPickerView: View { 11 | let limitEntity: InputNumberEntity 12 | let saveChargerLimit: EntityIdDoubleClosure 13 | @State var currentLimit: Double 14 | let pickerTextWidth: CGFloat = 60 15 | var body: some View { 16 | ZStack { 17 | FullScreenBackgroundOverlay() 18 | .onTapGesture { 19 | saveChargerLimit(limitEntity.entityId, currentLimit) 20 | } 21 | RoundedRectangle(cornerRadius: 40) 22 | .foregroundColor(.black) 23 | .opacity(0.7) 24 | .frame(width: pickerTextWidth * 4, 25 | height: 160) 26 | .onTapGesture { 27 | saveChargerLimit(limitEntity.entityId, currentLimit) 28 | } 29 | VStack { 30 | NumberPickerScrollView(entityId: limitEntity.entityId, 31 | targetNumber: $currentLimit, 32 | numberSelectedCallback: saveChargerLimit, 33 | pickerTextWidth: pickerTextWidth, 34 | strideFrom: 50, 35 | strideTo: 101, 36 | strideStep: 10) 37 | } 38 | } 39 | } 40 | } 41 | 42 | struct LimitPickerView_Previews: PreviewProvider { 43 | static var previews: some View { 44 | LimitPickerView(limitEntity: .init(entityId: .lynkDoorLock), saveChargerLimit: { _, _ in }, currentLimit: 50) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /IntelliNest/Views/Roborock/RoborockInfoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoborockInfoView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-06-22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RoborockInfoView: View { 11 | var lastCleanArea: String 12 | var batteryLevel: Int 13 | @Binding var showingMapView: Bool 14 | 15 | var body: some View { 16 | HStack(alignment: .center) { 17 | VStack(alignment: .leading) { 18 | Text("Senast städat: \(lastCleanArea.components(separatedBy: ".")[0]) m²") 19 | .foregroundColor(.white) 20 | Text("Batteri-nivå: \(batteryLevel)%") 21 | .foregroundColor(.white) 22 | } 23 | Button(action: { 24 | showingMapView = true 25 | }, label: { 26 | Image(systemName: "map.circle") 27 | .resizable(resizingMode: .stretch) 28 | .frame(width: 35, 29 | height: 35) 30 | .foregroundColor(.white) 31 | }) 32 | .padding(.leading) 33 | } 34 | } 35 | } 36 | 37 | struct RoborockInfo_Previews: PreviewProvider { 38 | static var previews: some View { 39 | RoborockInfoView( 40 | lastCleanArea: "", 41 | batteryLevel: 3, 42 | showingMapView: .constant(false) 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /IntelliNest/Views/Roborock/RoborockMainButtons.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoborockMainButtons.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-06-22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RoborockMainButtons: View { 11 | var roborock: RoborockEntity 12 | let toggleCleaningClosure: MainActorVoidClosure 13 | let dockRoborockClosure: MainActorVoidClosure 14 | let sendRoborockToBinClosure: MainActorVoidClosure 15 | let locateRoborockClosure: MainActorVoidClosure 16 | let manualEmptyClosure: MainActorVoidClosure 17 | 18 | var body: some View { 19 | HStack { 20 | Group { 21 | ServiceButtonView(buttonTitle: roborock.cleanButtonTitle, 22 | isActive: roborock.isCleaning, 23 | icon: roborock.cleanIcon, 24 | iconHeight: 25, 25 | action: toggleCleaningClosure) 26 | ServiceButtonView(buttonTitle: roborock.returnButtonTitle, 27 | isActive: roborock.isReturning, 28 | icon: roborock.returningIcon, 29 | imageSize: 25, 30 | action: dockRoborockClosure) 31 | ServiceButtonView(buttonTitle: "Hitta", 32 | icon: .init(systemImageName: .scope), 33 | imageSize: 25, 34 | action: locateRoborockClosure) 35 | ServiceButtonView(buttonTitle: "Töm", 36 | icon: .init(systemImageName: .trash), 37 | imageSize: 25, 38 | action: sendRoborockToBinClosure) 39 | .contextMenu { 40 | Button(action: manualEmptyClosure, label: { 41 | Text("Manuell tömning") 42 | }) 43 | } 44 | } 45 | } 46 | } 47 | } 48 | 49 | struct RoborockMainButtons_Previews: PreviewProvider { 50 | static var previews: some View { 51 | RoborockMainButtons( 52 | roborock: .init(entityId: .roborock, state: ""), 53 | toggleCleaningClosure: {}, 54 | dockRoborockClosure: {}, 55 | sendRoborockToBinClosure: {}, 56 | locateRoborockClosure: {}, 57 | manualEmptyClosure: {} 58 | ) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /IntelliNest/Views/Roborock/RoborockMapImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoborockMapImageView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-06-03. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RoborockMapImageView: View { 11 | @ObservedObject var viewModel: RoborockViewModel 12 | 13 | var body: some View { 14 | ZStack { 15 | FullScreenBackgroundOverlay() 16 | .onTapGesture { 17 | viewModel.isShowingMapView = false 18 | } 19 | if let url = URL(string: viewModel.imagageURLString) { 20 | AsyncImage(url: url) { phase in 21 | if let image = phase.image { 22 | ZoomableImageView(image: image, isPortrait: true, isFullScreen: .constant(false), initialScale: 1.5) 23 | .frame(width: 300, height: 330) 24 | } else if phase.error != nil { 25 | Image(systemName: "exclamationmark.triangle") 26 | .foregroundStyle(.red) 27 | .frame(width: 100, height: 100) 28 | .padding() 29 | } else { 30 | ProgressView() 31 | .frame(width: 100, height: 100) 32 | .padding() 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /IntelliNest/Views/Roborock/SinceEmptiedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SinceEmptiedView.swift 3 | // IntelliNest 4 | // 5 | // Created by Tobias on 2022-06-22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SinceEmptiedView: View { 11 | var emptiedAtDate: String 12 | var areaSinceEmpty: String 13 | 14 | var body: some View { 15 | HStack(alignment: .top) { 16 | Text("Senaste tömningen: ") 17 | .foregroundColor(.white) 18 | VStack(alignment: .trailing) { 19 | Text("\(emptiedAtDate.components(separatedBy: " ")[0])") 20 | .foregroundColor(.white) 21 | Text("\(areaSinceEmpty.components(separatedBy: ".")[0]) m²") 22 | .foregroundColor(.white) 23 | } 24 | Spacer() 25 | } 26 | } 27 | } 28 | 29 | struct SinceEmptied_Previews: PreviewProvider { 30 | static var previews: some View { 31 | SinceEmptiedView(emptiedAtDate: "", areaSinceEmpty: "") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /IntelliNestTests/InputNumberEntityTest.swift: -------------------------------------------------------------------------------- 1 | @testable import IntelliNest 2 | import XCTest 3 | 4 | class InputNumberEntityTests: XCTestCase { 5 | func testInit() { 6 | let entity = InputNumberEntity(entityId: .thermKitchen) 7 | 8 | XCTAssertEqual(entity.entityId, .thermKitchen) 9 | XCTAssertEqual(entity.state, "Loading") 10 | XCTAssertEqual(entity.lastChanged, .distantPast) 11 | XCTAssertEqual(entity.lastUpdated, .distantPast) 12 | XCTAssertEqual(entity.isActive, false) 13 | XCTAssertEqual(entity.inputNumber, 0) 14 | } 15 | 16 | func testInitFromDecoder() throws { 17 | let json = Data(""" 18 | { 19 | "entity_id": "input_number.kia_climate_temperature", 20 | "state": "21.0", 21 | "attributes": { 22 | "initial": null, 23 | "editable": true, 24 | "min": 16.0, 25 | "max": 26.0, 26 | "step": 0.5, 27 | "mode": "slider", 28 | "icon": "mdi:thermometer", 29 | "friendly_name": "Kia climate temperature" 30 | }, 31 | "last_changed": "2023-02-24T05:40:30.846631+00:00", 32 | "last_updated": "2023-02-24T05:40:30.846631+00:00", 33 | "context": { 34 | "id": "01GT0YZVZY8XCG69BJ0HE9VAN1", 35 | "parent_id": null, 36 | "user_id": null 37 | } 38 | } 39 | """.utf8) 40 | let decoder = JSONDecoder() 41 | let date = try XCTUnwrap(Date.fromISO8601("2023-02-24T05:40:30.846631+00:00")) 42 | let entity = try decoder.decode(InputNumberEntity.self, from: json) 43 | 44 | XCTAssertEqual(entity.state, "21.0") 45 | XCTAssertEqual(entity.lastChanged, date) 46 | XCTAssertEqual(entity.lastUpdated, date) 47 | XCTAssertEqual(entity.isActive, false) 48 | XCTAssertEqual(entity.inputNumber, 21.0) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /IntelliNestTests/LightEntityTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LightEntityTests.swift 3 | // IntelliNestTests 4 | // 5 | // Created by Tobias on 2023-06-17. 6 | // 7 | 8 | @testable import IntelliNest 9 | import XCTest 10 | 11 | final class LightEntityTests: XCTestCase { 12 | var light: LightEntity! 13 | 14 | override func setUpWithError() throws { 15 | try? super.setUpWithError() 16 | 17 | light = LightEntity(entityId: .sofa, state: "off") 18 | light.brightness = 103 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | light = nil 23 | try super.tearDownWithError() 24 | } 25 | 26 | func testDecode() throws { 27 | XCTAssertEqual(light.entityId, .sofa, "Failed to decode 'entityId'") 28 | XCTAssertEqual(light.state, "off", "Failed to decode 'state'") 29 | XCTAssertEqual(light.brightness, 103, "Failed to decode 'brightness'") 30 | XCTAssertEqual(light.isActive, false, "Failed to decode 'isActive'") 31 | XCTAssertNotNil(light.nextUpdate, "Failed to decode 'nextUpdate'") 32 | } 33 | 34 | func testUpdateIsActive() { 35 | light.updateIsActive() 36 | XCTAssertEqual(light.isActive, false, "Failed to update 'isActive'") 37 | } 38 | 39 | func testSetNextUpdateTime() { 40 | light.setNextUpdateTime() 41 | XCTAssertNotNil(light.nextUpdate, "Failed to set 'nextUpdate'") 42 | } 43 | 44 | func testEquality() throws { 45 | let copy = light 46 | XCTAssertEqual(light, copy, "Failed to test equality") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /IntelliNestTests/LockEntityTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LockEntityTest.swift 3 | // IntelliNestTests 4 | // 5 | // Created by Tobias on 2022-05-23. 6 | // 7 | 8 | import XCTest 9 | 10 | @testable import IntelliNest 11 | class LockEntityTest: XCTestCase { 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | } 15 | 16 | override func tearDownWithError() throws { 17 | // Put teardown code here. This method is called after the invocation of each test method in the class. 18 | } 19 | 20 | func testStateToString() { 21 | // Given 22 | var lockEntity = LockEntity(entityId: EntityId.framdorren, state: "Loading") 23 | lockEntity.state = "unlocked" 24 | let translatedStateUnlocked = lockEntity.stateToString() 25 | lockEntity.state = "locked" 26 | let translatedStateLocked = lockEntity.stateToString() 27 | lockEntity.state = "locking" 28 | let translatedStateLocking = lockEntity.stateToString() 29 | lockEntity.state = "unlocking" 30 | let translatedStateUnlocking = lockEntity.stateToString() 31 | 32 | // When 33 | 34 | // Then 35 | XCTAssertEqual("olåst", translatedStateUnlocked) 36 | XCTAssertEqual("låst", translatedStateLocked) 37 | XCTAssertEqual("låser", translatedStateLocking) 38 | XCTAssertEqual("låser upp", translatedStateUnlocking) 39 | } 40 | 41 | func testActionText() { 42 | // Given 43 | var lockEntity = LockEntity(entityId: EntityId.framdorren, state: "Loading") 44 | lockEntity.state = "unlocked" 45 | let lockEntityUnlocked = lockEntity 46 | lockEntity.state = "locked" 47 | let lockEntityLocked = lockEntity 48 | lockEntity.state = "unlocking" 49 | let lockEntityUnlocking = lockEntity 50 | lockEntity.state = "locking" 51 | let lockEntityLocking = lockEntity 52 | 53 | // When 54 | 55 | // Then 56 | XCTAssertEqual("Lås", lockEntityUnlocked.actionText) 57 | XCTAssertEqual("Lås upp", lockEntityLocked.actionText) 58 | XCTAssertEqual("Låser upp", lockEntityUnlocking.actionText) 59 | XCTAssertEqual("Låser", lockEntityLocking.actionText) 60 | } 61 | 62 | func testCalculateIsActive() { 63 | // Given 64 | var lockEntity = LockEntity(entityId: EntityId.framdorren, state: "Loading") 65 | lockEntity.state = "unlocked" 66 | let lockEntityUnlocked = lockEntity 67 | lockEntity.state = "locked" 68 | let lockEntityLocked = lockEntity 69 | lockEntity.state = "unlocking" 70 | let lockEntityUnlocking = lockEntity 71 | lockEntity.state = "locking" 72 | let lockEntityLocking = lockEntity 73 | // When 74 | // ... 75 | 76 | // Then 77 | XCTAssertTrue(lockEntityUnlocked.isActive) 78 | XCTAssertFalse(lockEntityLocked.isActive) 79 | XCTAssertTrue(lockEntityUnlocking.isActive) 80 | XCTAssertTrue(lockEntityLocking.isActive) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /IntelliNestTests/URLProtocolStub.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLProtocolStub.swift 3 | // IntelliNestTests 4 | // 5 | // Created by Tobias on 2023-05-06. 6 | // 7 | 8 | import Foundation 9 | 10 | class URLProtocolStub: URLProtocol { 11 | private static var stubs: [URL: Stub] = [:] 12 | private static var requestObserver: ((URLRequest) -> Void)? 13 | 14 | private struct Stub { 15 | let data: Data? 16 | let response: URLResponse? 17 | let error: Error? 18 | } 19 | 20 | static func setStub(for url: URL, data: Data?, response: URLResponse?, error: Error?) { 21 | let stub = Stub(data: data, response: response, error: error) 22 | stubs[url] = stub 23 | } 24 | 25 | static func observerRequests(observer: @escaping (URLRequest) -> Void) { 26 | requestObserver = observer 27 | } 28 | 29 | static func startInterceptingRequests() { 30 | URLProtocolStub.registerClass(URLProtocolStub.self) 31 | } 32 | 33 | static func stopInterceptingRequests() { 34 | URLProtocolStub.unregisterClass(URLProtocolStub.self) 35 | stubs.removeAll() 36 | requestObserver = nil 37 | } 38 | 39 | override class func canInit(with request: URLRequest) -> Bool { 40 | requestObserver?(request) 41 | return true 42 | } 43 | 44 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 45 | request 46 | } 47 | 48 | override func startLoading() { 49 | if let url = request.url, let stub = URLProtocolStub.stubs[url] { 50 | if let data = stub.data { 51 | client?.urlProtocol(self, didLoad: data) 52 | } 53 | 54 | if let response = stub.response { 55 | client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) 56 | } 57 | 58 | if let error = stub.error { 59 | client?.urlProtocol(self, didFailWithError: error) 60 | } 61 | } 62 | client?.urlProtocolDidFinishLoading(self) 63 | } 64 | 65 | override func stopLoading() {} 66 | 67 | static func createStubbedURLSession() -> URLSession { 68 | let configuration = URLSessionConfiguration.ephemeral 69 | configuration.protocolClasses = [URLProtocolStub.self] 70 | let urlSession = URLSession(configuration: configuration) 71 | return urlSession 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /IntelliWidget/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /IntelliWidget/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /IntelliWidget/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /IntelliWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /IntelliWidget/Assets.xcassets/widget-home-icon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "widget-home-icon.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | }, 21 | "properties" : { 22 | "preserves-vector-representation" : true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /IntelliWidget/Assets.xcassets/widget-home-icon.imageset/widget-home-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TobiasLaross/IntelliNest/627123f9c34778d8c69efbb70655666d8bfc6075/IntelliWidget/Assets.xcassets/widget-home-icon.imageset/widget-home-icon.png -------------------------------------------------------------------------------- /IntelliWidget/CarHeaterWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CarHeaterWidget.swift 3 | // IntelliWidget 4 | // 5 | // Created by Tobias on 2024-01-12. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | struct CarHeaterEntryView: View { 12 | var entry: SimpleEntry 13 | let frameSize = 55.0 14 | 15 | var body: some View { 16 | ZStack { 17 | AccessoryWidgetBackground() 18 | Link(destination: URL(string: "IntelliNest://start-car-heater")!) { 19 | Image(systemName: "car") 20 | .resizable() 21 | .aspectRatio(contentMode: .fit) 22 | .padding(12) 23 | } 24 | } 25 | } 26 | } 27 | 28 | @MainActor 29 | struct Provider: @preconcurrency TimelineProvider { 30 | @preconcurrency func placeholder(in context: Context) -> SimpleEntry { 31 | SimpleEntry(date: Date(), isSarahsPillsTaken: false) 32 | } 33 | 34 | @preconcurrency func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) { 35 | let entry = createEntry() 36 | completion(entry) 37 | } 38 | 39 | @preconcurrency func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { 40 | let entry = createEntry() 41 | 42 | var calendar = Calendar.current 43 | calendar.timeZone = TimeZone.current 44 | let now = Date() 45 | guard let nextMidnight = calendar.nextDate(after: now, 46 | matching: DateComponents(hour: 0, minute: 0, second: 0), 47 | matchingPolicy: .nextTime) else { 48 | let timeline = Timeline(entries: [entry], policy: .after(now.addingTimeInterval(86400))) 49 | completion(timeline) 50 | return 51 | } 52 | 53 | let midnightEntry = SimpleEntry(date: nextMidnight, isSarahsPillsTaken: false) 54 | let timeline = Timeline(entries: [entry, midnightEntry], policy: .after(nextMidnight)) 55 | completion(timeline) 56 | } 57 | 58 | private func createEntry() -> SimpleEntry { 59 | let lastTakenPillsDate = UserDefaults.shared.value(forKey: StorageKeys.sarahPills.rawValue) as? Date 60 | let isSarahsPillsTaken = Calendar.current.isDateInToday(lastTakenPillsDate ?? .distantPast) 61 | return SimpleEntry(date: Date(), isSarahsPillsTaken: isSarahsPillsTaken) 62 | } 63 | } 64 | 65 | struct SimpleEntry: TimelineEntry { 66 | let date: Date 67 | let isSarahsPillsTaken: Bool 68 | } 69 | 70 | struct CarHeaterWidget: Widget { 71 | let kind: String = "CarHeaterWidget" 72 | 73 | var body: some WidgetConfiguration { 74 | StaticConfiguration(kind: kind, provider: Provider()) { entry in 75 | CarHeaterEntryView(entry: entry) 76 | .containerBackground(.fill.tertiary, for: .widget) 77 | } 78 | .configurationDisplayName("IntelliWidget") 79 | .description("Tap the car to activate car heater") 80 | .supportedFamilies([.accessoryCircular]) 81 | } 82 | } 83 | 84 | struct IntelliWidget_Previews: PreviewProvider { 85 | static var previews: some View { 86 | CarHeaterEntryView(entry: SimpleEntry(date: Date(), isSarahsPillsTaken: false)) 87 | .containerBackground(.fill.tertiary, for: .widget) 88 | .previewContext(WidgetPreviewContext(family: .accessoryRectangular)) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /IntelliWidget/HomeWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeWidget.swift 3 | // IntelliWidgetExtension 4 | // 5 | // Created by Tobias on 2024-01-20. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | struct HomeWidgetEntryView: View { 12 | var entry: SimpleEntry 13 | 14 | var body: some View { 15 | ZStack { 16 | AccessoryWidgetBackground() 17 | Link(destination: URL(string: "IntelliNest://home")!) { 18 | if UserManager.currentUser == .sarah && !entry.isSarahsPillsTaken { 19 | Image(systemName: "pills.fill") 20 | .resizable() 21 | .aspectRatio(contentMode: .fit) 22 | .padding(10) 23 | } else { 24 | // Image("widget-home-icon") 25 | Image(systemName: "house") 26 | .resizable() 27 | .aspectRatio(contentMode: .fit) 28 | .padding(12) 29 | } 30 | } 31 | } 32 | } 33 | } 34 | 35 | struct HomeWidget: Widget { 36 | let kind: String = "HomeWidget" 37 | 38 | var body: some WidgetConfiguration { 39 | StaticConfiguration(kind: kind, provider: Provider()) { entry in 40 | HomeWidgetEntryView(entry: entry) 41 | .containerBackground(.fill.tertiary, for: .widget) 42 | } 43 | .configurationDisplayName("IntelliWidget") 44 | .description("Tap the house to navigate to home screen") 45 | .supportedFamilies([.accessoryCircular]) 46 | } 47 | } 48 | 49 | #Preview { 50 | HomeWidgetEntryView(entry: .init(date: Date(), isSarahsPillsTaken: true)) 51 | } 52 | -------------------------------------------------------------------------------- /IntelliWidget/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionAttributes 8 | 9 | WKWidget 10 | 11 | SupportedFamilies 12 | 13 | accessoryCircular 14 | 15 | 16 | 17 | NSExtensionPointIdentifier 18 | com.apple.widgetkit-extension 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /IntelliWidget/IntelliWidgetBundle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntelliWidgetBundle.swift 3 | // IntelliWidget 4 | // 5 | // Created by Tobias on 2024-01-12. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | @main 12 | struct IntelliWidgetBundle: WidgetBundle { 13 | var body: some Widget { 14 | HomeWidget() 15 | CarHeaterWidget() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /IntelliWidget/IntelliWidgetExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.se.laross.intellinest.shared 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Tobias Laross 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /buildServer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xcode build server", 3 | "version": "0.2", 4 | "bspVersion": "2.0", 5 | "languages": [ 6 | "c", 7 | "cpp", 8 | "objective-c", 9 | "objective-cpp", 10 | "swift" 11 | ], 12 | "argv": [ 13 | "/opt/homebrew/bin/xcode-build-server" 14 | ], 15 | "workspace": "/Users/tobias/Developer/personal/IntelliNest/IntelliNest.xcodeproj/project.xcworkspace", 16 | "build_root": "/Users/tobias/Library/Developer/Xcode/DerivedData/IntelliNest-dadbqqzjlyieqohfxijjwikewmvx", 17 | "scheme": "IntelliNest", 18 | "kind": "xcode" 19 | } --------------------------------------------------------------------------------