Resolves #340 Connection refactoring
This commit is contained in:
		
							
								
								
									
										44
									
								
								lib/auth_manager.class.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								lib/auth_manager.class.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | |||||||
|  | part of 'main.dart'; | ||||||
|  |  | ||||||
|  | class AuthManager { | ||||||
|  |  | ||||||
|  |   static final AuthManager _instance = AuthManager._internal(); | ||||||
|  |  | ||||||
|  |   factory AuthManager() { | ||||||
|  |     return _instance; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   AuthManager._internal(); | ||||||
|  |  | ||||||
|  |   Future getTempToken({String httpWebHost, String oauthUrl}) { | ||||||
|  |     Completer completer = Completer(); | ||||||
|  |     final flutterWebviewPlugin = new FlutterWebviewPlugin(); | ||||||
|  |     flutterWebviewPlugin.onUrlChanged.listen((String url) { | ||||||
|  |       if (url.startsWith("http://ha-client.homemade.systems/service/auth_callback.html")) { | ||||||
|  |         String authCode = url.split("=")[1]; | ||||||
|  |         Logger.d("We have auth code. Getting temporary access token..."); | ||||||
|  |         Connection().sendHTTPPost( | ||||||
|  |             host: httpWebHost, | ||||||
|  |             endPoint: "/auth/token", | ||||||
|  |             contentType: "application/x-www-form-urlencoded", | ||||||
|  |             includeAuthHeader: false, | ||||||
|  |             data: "grant_type=authorization_code&code=$authCode&client_id=${Uri.encodeComponent('http://ha-client.homemade.systems/')}" | ||||||
|  |         ).then((response) { | ||||||
|  |           Logger.d("Gottemp token"); | ||||||
|  |           String tempToken = json.decode(response)['access_token']; | ||||||
|  |           Logger.d("Closing webview..."); | ||||||
|  |           flutterWebviewPlugin.close(); | ||||||
|  |           completer.complete(tempToken); | ||||||
|  |         }).catchError((e) { | ||||||
|  |           flutterWebviewPlugin.close(); | ||||||
|  |           completer.completeError({"errorCode": 61, "errorMessage": "Error getting temp token"}); | ||||||
|  |           Logger.e("Error getting temp token: ${e.toString()}"); | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |     Logger.d("Launching OAuth..."); | ||||||
|  |     eventBus.fire(StartAuthEvent(oauthUrl)); | ||||||
|  |     return completer.future; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										330
									
								
								lib/connection.class.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										330
									
								
								lib/connection.class.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,330 @@ | |||||||
|  | part of 'main.dart'; | ||||||
|  |  | ||||||
|  | class Connection { | ||||||
|  |  | ||||||
|  |   static final Connection _instance = Connection._internal(); | ||||||
|  |  | ||||||
|  |   factory Connection() { | ||||||
|  |     return _instance; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Connection._internal(); | ||||||
|  |  | ||||||
|  |   String displayHostname; | ||||||
|  |   String _webSocketAPIEndpoint; | ||||||
|  |   String httpWebHost; | ||||||
|  |   String _token; | ||||||
|  |   String _tempToken; | ||||||
|  |   String oauthUrl; | ||||||
|  |   bool get isAuthenticated => _token != null; | ||||||
|  |   StreamSubscription _socketSubscription; | ||||||
|  |   Duration connectTimeout = Duration(seconds: 15); | ||||||
|  |  | ||||||
|  |   bool isConnected = false; | ||||||
|  |  | ||||||
|  |   var onStateChangeCallback; | ||||||
|  |  | ||||||
|  |   IOWebSocketChannel _socket; | ||||||
|  |  | ||||||
|  |   int _currentMessageId = 0; | ||||||
|  |   Map<String, Completer> _messageResolver = {}; | ||||||
|  |  | ||||||
|  |   Future init(onStateChange) async { | ||||||
|  |     Completer completer = Completer(); | ||||||
|  |     onStateChangeCallback = onStateChange; | ||||||
|  |     SharedPreferences prefs = await SharedPreferences.getInstance(); | ||||||
|  |     String domain = prefs.getString('hassio-domain'); | ||||||
|  |     String port = prefs.getString('hassio-port'); | ||||||
|  |     displayHostname = "$domain:$port"; | ||||||
|  |     _webSocketAPIEndpoint = "${prefs.getString('hassio-protocol')}://$domain:$port/api/websocket"; | ||||||
|  |     httpWebHost = "${prefs.getString('hassio-res-protocol')}://$domain:$port"; | ||||||
|  |     //_token = prefs.getString('hassio-token'); | ||||||
|  |     final storage = new FlutterSecureStorage(); | ||||||
|  |     try { | ||||||
|  |       _token = await storage.read(key: "hacl_llt"); | ||||||
|  |     } catch (e) { | ||||||
|  |       Logger.e("Cannt read secure storage. Need to relogin."); | ||||||
|  |       _token = null; | ||||||
|  |       await storage.delete(key: "hacl_llt"); | ||||||
|  |     } | ||||||
|  |     if ((domain == null) || (port == null) || | ||||||
|  |         (domain.length == 0) || (port.length == 0)) { | ||||||
|  |       completer.completeError({"errorCode": 5, "errorMessage": "Check connection settings"}); | ||||||
|  |     } else { | ||||||
|  |       oauthUrl = "$httpWebHost/auth/authorize?client_id=${Uri.encodeComponent('http://ha-client.homemade.systems/')}&redirect_uri=${Uri.encodeComponent('http://ha-client.homemade.systems/service/auth_callback.html')}"; | ||||||
|  |       if (_token == null) { | ||||||
|  |         await AuthManager().getTempToken( | ||||||
|  |             httpWebHost: httpWebHost, | ||||||
|  |             oauthUrl: oauthUrl | ||||||
|  |         ).then((token) { | ||||||
|  |           Logger.d("Token from AuthManager recived"); | ||||||
|  |           _tempToken = token; | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |       completer.complete(_connect()); | ||||||
|  |     } | ||||||
|  |     return completer.future; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future _connect() async { | ||||||
|  |     Completer completer = Completer(); | ||||||
|  |     Timer connectionTimer = Timer(connectTimeout, () { | ||||||
|  |       if (!completer.isCompleted) completer.completeError({"errorCode": 1, "errorMessage": "Connection timeout"}); | ||||||
|  |     }); | ||||||
|  |     await _disconnect(); | ||||||
|  |     Logger.d( "Socket connecting..."); | ||||||
|  |     _socket = IOWebSocketChannel.connect( | ||||||
|  |         _webSocketAPIEndpoint, pingInterval: Duration(seconds: 15)); | ||||||
|  |     _socketSubscription = _socket.stream.listen( | ||||||
|  |             (message) { | ||||||
|  |           isConnected = true; | ||||||
|  |           connectionTimer.cancel(); | ||||||
|  |           var data = json.decode(message); | ||||||
|  |           if (data["type"] == "auth_required") { | ||||||
|  |             Logger.d("[Received] <== ${data.toString()}"); | ||||||
|  |             _authenticate().then((_) => completer.complete()).catchError((e) { | ||||||
|  |               if (!completer.isCompleted) completer.completeError(e); | ||||||
|  |             }); | ||||||
|  |           } else if (data["type"] == "auth_ok") { | ||||||
|  |             Logger.d("[Received] <== ${data.toString()}"); | ||||||
|  |             _messageResolver["auth"]?.complete(); | ||||||
|  |             _messageResolver.remove("auth"); | ||||||
|  |             if (!completer.isCompleted) completer.complete(sendSocketMessage( | ||||||
|  |               type: "subscribe_events", | ||||||
|  |               additionalData: {"event_type": "state_changed"}, | ||||||
|  |             )); | ||||||
|  |           } else if (data["type"] == "auth_invalid") { | ||||||
|  |             Logger.d("[Received] <== ${data.toString()}"); | ||||||
|  |             _messageResolver["auth"]?.completeError({"errorCode": 62, "errorMessage": "${data["message"]}"}); | ||||||
|  |             _messageResolver.remove("auth"); | ||||||
|  |             logout().then((_) { | ||||||
|  |               if (!completer.isCompleted) completer.completeError({"errorCode": 62, "errorMessage": "${data["message"]}"}); | ||||||
|  |             }); | ||||||
|  |           } else { | ||||||
|  |             _handleMessage(data); | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         cancelOnError: true, | ||||||
|  |         onDone: () => _handleSocketClose(completer), | ||||||
|  |         onError: (e) => _handleSocketError(e, completer) | ||||||
|  |     ); | ||||||
|  |     return completer.future; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future _disconnect() async { | ||||||
|  |     Logger.d( "Socket disconnecting..."); | ||||||
|  |     await _socketSubscription?.cancel(); | ||||||
|  |     await _socket?.sink?.close()?.timeout(Duration(seconds: 4), | ||||||
|  |         onTimeout: () => Logger.d( "Socket sink close timeout") | ||||||
|  |     ); | ||||||
|  |     Logger.d( "..Disconnected"); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   _handleMessage(data) { | ||||||
|  |     if (data["type"] == "result") { | ||||||
|  |       if (data["id"] != null && data["success"]) { | ||||||
|  |         Logger.d("[Received] <== Request id ${data['id']} was successful"); | ||||||
|  |         _messageResolver["${data["id"]}"]?.complete(data["result"]); | ||||||
|  |       } else if (data["id"] != null) { | ||||||
|  |         Logger.e("[Received] <== Error received on request id ${data['id']}: ${data['error']}"); | ||||||
|  |         _messageResolver["${data["id"]}"]?.completeError({"errorMessage": "${data['error']["message"]}"}); | ||||||
|  |       } | ||||||
|  |       _messageResolver.remove("${data["id"]}"); | ||||||
|  |     } else if (data["type"] == "event") { | ||||||
|  |       if ((data["event"] != null) && (data["event"]["event_type"] == "state_changed")) { | ||||||
|  |         Logger.d("[Received] <== ${data['type']}.${data["event"]["event_type"]}: ${data["event"]["data"]["entity_id"]}"); | ||||||
|  |         onStateChangeCallback(data["event"]["data"]); | ||||||
|  |       } else if (data["event"] != null) { | ||||||
|  |         Logger.w("Unhandled event type: ${data["event"]["event_type"]}"); | ||||||
|  |       } else { | ||||||
|  |         Logger.e("Event is null: $data"); | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       Logger.d("[Received unhandled] <== ${data.toString()}"); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _handleSocketClose(Completer connectionCompleter) { | ||||||
|  |     isConnected = false; | ||||||
|  |     Logger.d("Socket disconnected."); | ||||||
|  |     if (!connectionCompleter.isCompleted) { | ||||||
|  |       connectionCompleter.completeError({"errorCode": 82, "errorMessage": "Disconnected"}); | ||||||
|  |     } else { | ||||||
|  |       //TODO improve | ||||||
|  |       _disconnect().then((_) { | ||||||
|  |         Timer(Duration(seconds: 5), () { | ||||||
|  |           Logger.d("Trying to reconnect..."); | ||||||
|  |           _connect(); | ||||||
|  |         }); | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _handleSocketError(e, Completer connectionCompleter) { | ||||||
|  |     isConnected = false; | ||||||
|  |     Logger.e("Socket stream Error: $e"); | ||||||
|  |     if (!connectionCompleter.isCompleted) { | ||||||
|  |       connectionCompleter.completeError({"errorCode": 81, "errorMessage": "Unable to connect to Home Assistant"}); | ||||||
|  |     } else { | ||||||
|  |       //TODO improve | ||||||
|  |       _disconnect().then((_) { | ||||||
|  |         Timer(Duration(seconds: 5), () { | ||||||
|  |           Logger.d("Trying to reconnect..."); | ||||||
|  |           _connect(); | ||||||
|  |         }); | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future _authenticate() { | ||||||
|  |     Completer completer = Completer(); | ||||||
|  |     if (_token != null) { | ||||||
|  |       Logger.d( "Long-lived token exist"); | ||||||
|  |       Logger.d( "[Sending] ==> auth request"); | ||||||
|  |       sendSocketMessage( | ||||||
|  |           type: "auth", | ||||||
|  |           additionalData: {"access_token": "$_token"}, | ||||||
|  |           auth: true | ||||||
|  |       ).then((_) { | ||||||
|  |         completer.complete(); | ||||||
|  |       }).catchError((e) => completer.completeError(e)); | ||||||
|  |     } else if (_tempToken != null) { | ||||||
|  |       Logger.d("We have temp token. Loging in..."); | ||||||
|  |       sendSocketMessage( | ||||||
|  |           type: "auth", | ||||||
|  |           additionalData: {"access_token": "$_tempToken"}, | ||||||
|  |           auth: true | ||||||
|  |       ).then((_) { | ||||||
|  |         Logger.d("Requesting long-lived token..."); | ||||||
|  |         _getLongLivedToken().then((_) { | ||||||
|  |           completer.complete(); | ||||||
|  |         }).catchError((e) { | ||||||
|  |           Logger.e("Can't get long-lived token: $e"); | ||||||
|  |           throw e; | ||||||
|  |         }); | ||||||
|  |       }).catchError((e) => completer.completeError(e)); | ||||||
|  |     } else { | ||||||
|  |       completer.completeError({"errorCode": 63, "errorMessage": "General login error"}); | ||||||
|  |     } | ||||||
|  |     return completer.future; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future logout() { | ||||||
|  |     _token = null; | ||||||
|  |     _tempToken = null; | ||||||
|  |     final storage = new FlutterSecureStorage(); | ||||||
|  |     return storage.delete(key: "hacl_llt"); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future _getLongLivedToken() { | ||||||
|  |     Completer completer = Completer(); | ||||||
|  |     sendSocketMessage(type: "auth/long_lived_access_token", additionalData: {"client_name": "HA Client app ${DateTime.now().millisecondsSinceEpoch}", "lifespan": 365}).then((data) { | ||||||
|  |       Logger.d("Got long-lived token."); | ||||||
|  |       _token = data; | ||||||
|  |       _tempToken = null; | ||||||
|  |       final storage = new FlutterSecureStorage(); | ||||||
|  |       storage.write(key: "hacl_llt", value: "$_token").then((_) { | ||||||
|  |         completer.complete(); | ||||||
|  |       }).catchError((e) { | ||||||
|  |         throw e; | ||||||
|  |       }); | ||||||
|  |     }).catchError((e) { | ||||||
|  |       logout(); | ||||||
|  |       completer.completeError({"errorCode": 63, "errorMessage": "Authentication error: $e"}); | ||||||
|  |     }); | ||||||
|  |     return completer.future; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future sendSocketMessage({String type, Map additionalData, bool auth: false}) { | ||||||
|  |     Completer _completer = Completer(); | ||||||
|  |     if (!isConnected) { | ||||||
|  |       _completer.completeError({"errorCode": 8, "errorMessage": "No connection to Home Assistant"}); | ||||||
|  |     } | ||||||
|  |     Map dataObject = {"type": "$type"}; | ||||||
|  |     String callbackName; | ||||||
|  |     if (!auth) { | ||||||
|  |       _incrementMessageId(); | ||||||
|  |       dataObject["id"] = _currentMessageId; | ||||||
|  |       callbackName = "$_currentMessageId"; | ||||||
|  |     } else { | ||||||
|  |       callbackName = "auth"; | ||||||
|  |     } | ||||||
|  |     if (additionalData != null) { | ||||||
|  |       dataObject.addAll(additionalData); | ||||||
|  |     } | ||||||
|  |     _messageResolver[callbackName] = _completer; | ||||||
|  |     //TODO add message to q and send after reconnect | ||||||
|  |     String rawMessage = json.encode(dataObject); | ||||||
|  |     Logger.d("[Sending] ==> $rawMessage"); | ||||||
|  |     _socket.sink.add(rawMessage); | ||||||
|  |     return _completer.future; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void _incrementMessageId() { | ||||||
|  |     _currentMessageId += 1; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future callService({String domain, String service, String entityId, Map additionalServiceData}) { | ||||||
|  |     Map serviceData = {}; | ||||||
|  |     if (entityId != null) { | ||||||
|  |       serviceData["entity_id"] = entityId; | ||||||
|  |     } | ||||||
|  |     if (additionalServiceData != null && additionalServiceData.isNotEmpty) { | ||||||
|  |       serviceData.addAll(additionalServiceData); | ||||||
|  |     } | ||||||
|  |     if (serviceData.isNotEmpty) | ||||||
|  |       return sendSocketMessage(type: "call_service", additionalData: {"domain": domain, "service": service, "service_data": serviceData}); | ||||||
|  |     else | ||||||
|  |       return sendSocketMessage(type: "call_service", additionalData: {"domain": domain, "service": service}); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<List> getHistory(String entityId) async { | ||||||
|  |     DateTime now = DateTime.now(); | ||||||
|  |     //String endTime = formatDate(now, [yyyy, '-', mm, '-', dd, 'T', HH, ':', nn, ':', ss, z]); | ||||||
|  |     String startTime = formatDate(now.subtract(Duration(hours: 24)), [yyyy, '-', mm, '-', dd, 'T', HH, ':', nn, ':', ss, z]); | ||||||
|  |     String url = "$httpWebHost/api/history/period/$startTime?&filter_entity_id=$entityId"; | ||||||
|  |     Logger.d("[Sending] ==> $url"); | ||||||
|  |     http.Response historyResponse; | ||||||
|  |     historyResponse = await http.get(url, headers: { | ||||||
|  |       "authorization": "Bearer $_token", | ||||||
|  |       "Content-Type": "application/json" | ||||||
|  |     }); | ||||||
|  |     var history = json.decode(historyResponse.body); | ||||||
|  |     if (history is List) { | ||||||
|  |       Logger.d( "[Received] <== ${history.first.length} history recors"); | ||||||
|  |       return history; | ||||||
|  |     } else { | ||||||
|  |       return []; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future sendHTTPPost({String host, String endPoint, String data, String contentType: "application/json", bool includeAuthHeader: true, String authToken}) async { | ||||||
|  |     Completer completer = Completer(); | ||||||
|  |     String url = "$host$endPoint"; | ||||||
|  |     Logger.d("[Sending] ==> $url"); | ||||||
|  |     Map<String, String> headers = {}; | ||||||
|  |     if (contentType != null) { | ||||||
|  |       headers["Content-Type"] = contentType; | ||||||
|  |     } | ||||||
|  |     if (includeAuthHeader) { | ||||||
|  |       headers["authorization"] = "Bearer $authToken"; | ||||||
|  |     } | ||||||
|  |     http.post( | ||||||
|  |         url, | ||||||
|  |         headers: headers, | ||||||
|  |         body: data | ||||||
|  |     ).then((response) { | ||||||
|  |       Logger.d("[Received] <== ${response.statusCode}, ${response.body}"); | ||||||
|  |       if (response.statusCode == 200) { | ||||||
|  |         completer.complete(response.body); | ||||||
|  |       } else { | ||||||
|  |         completer.completeError({"code": response.statusCode, "message": "${response.body}"}); | ||||||
|  |       } | ||||||
|  |     }).catchError((e) { | ||||||
|  |       completer.completeError(e); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     return completer.future; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -131,7 +131,7 @@ class _CameraStreamViewState extends State<CameraStreamView> { | |||||||
|           .of(context) |           .of(context) | ||||||
|           .entityWrapper |           .entityWrapper | ||||||
|           .entity; |           .entity; | ||||||
|       _webHost = HomeAssistantModel.of(context).homeAssistant.httpWebHost; |       _webHost = HomeAssistantModel.of(context).homeAssistant.connection.httpWebHost; | ||||||
|       _connect(); |       _connect(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -47,7 +47,7 @@ class _EntityHistoryWidgetState extends State<EntityHistoryWidget> { | |||||||
|     } |     } | ||||||
|     if (_historyLastUpdated == null || now.difference(_historyLastUpdated).inSeconds > 30) { |     if (_historyLastUpdated == null || now.difference(_historyLastUpdated).inSeconds > 30) { | ||||||
|       _historyLastUpdated = now; |       _historyLastUpdated = now; | ||||||
|       ha.getHistory(entityId).then((history){ |       ha.connection.getHistory(entityId).then((history){ | ||||||
|         if (!_disposed) { |         if (!_disposed) { | ||||||
|           setState(() { |           setState(() { | ||||||
|             _history = history.isNotEmpty ? history[0] : []; |             _history = history.isNotEmpty ? history[0] : []; | ||||||
|   | |||||||
| @@ -1,20 +1,15 @@ | |||||||
| part of 'main.dart'; | part of 'main.dart'; | ||||||
|  |  | ||||||
| class HomeAssistant { | class HomeAssistant { | ||||||
|   String _webSocketAPIEndpoint; |  | ||||||
|   String httpWebHost; |   final Connection connection = Connection(); | ||||||
|   String oauthUrl; |  | ||||||
|   //String _password; |  | ||||||
|   String _token; |  | ||||||
|   String _tempToken; |  | ||||||
|   bool _useLovelace = false; |   bool _useLovelace = false; | ||||||
|   bool isSettingsLoaded = false; |   //bool isSettingsLoaded = false; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|   IOWebSocketChannel _hassioChannel; |  | ||||||
|   SendMessageQueue _messageQueue; |  | ||||||
|  |  | ||||||
|   int _currentMessageId = 0; |  | ||||||
|   Map<int, Completer> _messageResolver = {}; |  | ||||||
|   EntityCollection entities; |   EntityCollection entities; | ||||||
|   HomeAssistantUI ui; |   HomeAssistantUI ui; | ||||||
|   Map _instanceConfig = {}; |   Map _instanceConfig = {}; | ||||||
| @@ -26,17 +21,7 @@ class HomeAssistant { | |||||||
|  |  | ||||||
|   List<Panel> panels = []; |   List<Panel> panels = []; | ||||||
|  |  | ||||||
|   Completer _fetchCompleter; |  | ||||||
|   Completer _connectionCompleter; |  | ||||||
|   Timer _connectionTimer; |  | ||||||
|   Timer _fetchTimer; |  | ||||||
|   bool autoReconnect = false; |  | ||||||
|  |  | ||||||
|   StreamSubscription _socketSubscription; |  | ||||||
|  |  | ||||||
|   int messageExpirationTime = 30; //seconds |  | ||||||
|   Duration fetchTimeout = Duration(seconds: 30); |   Duration fetchTimeout = Duration(seconds: 30); | ||||||
|   Duration connectTimeout = Duration(seconds: 15); |  | ||||||
|  |  | ||||||
|   String get locationName { |   String get locationName { | ||||||
|     if (_useLovelace) { |     if (_useLovelace) { | ||||||
| @@ -49,253 +34,65 @@ class HomeAssistant { | |||||||
|   String get userAvatarText => userName.length > 0 ? userName[0] : ""; |   String get userAvatarText => userName.length > 0 ? userName[0] : ""; | ||||||
|   bool get isNoEntities => entities == null || entities.isEmpty; |   bool get isNoEntities => entities == null || entities.isEmpty; | ||||||
|   bool get isNoViews => ui == null || ui.isEmpty; |   bool get isNoViews => ui == null || ui.isEmpty; | ||||||
|   bool get isAuthenticated => _token != null; |  | ||||||
|   //int get viewsCount => entities.views.length ?? 0; |   //int get viewsCount => entities.views.length ?? 0; | ||||||
|  |  | ||||||
|   HomeAssistant() { |   HomeAssistant(); | ||||||
|     _messageQueue = SendMessageQueue(messageExpirationTime); |  | ||||||
|  |   Completer _connectCompleter; | ||||||
|  |  | ||||||
|  |   Future init() { | ||||||
|  |     if (_connectCompleter != null && !_connectCompleter.isCompleted) { | ||||||
|  |       Logger.w("Previous connection pending..."); | ||||||
|  |       return _connectCompleter.future; | ||||||
|  |     } | ||||||
|  |     Logger.d("init..."); | ||||||
|  |     _connectCompleter = Completer(); | ||||||
|  |     connection.init(_handleEntityStateChange).then((_) { | ||||||
|  |       SharedPreferences.getInstance().then((prefs) { | ||||||
|  |         if (entities == null) entities = EntityCollection(connection.httpWebHost); | ||||||
|  |         _useLovelace = prefs.getBool('use-lovelace') ?? true; | ||||||
|  |         _connectCompleter.complete(); | ||||||
|  |       }).catchError((e) => _connectCompleter.completeError(e)); | ||||||
|  |     }).catchError((e) => _connectCompleter.completeError(e)); | ||||||
|  |     return _connectCompleter.future; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future loadConnectionSettings() async { |   Completer _fetchCompleter; | ||||||
|     SharedPreferences prefs = await SharedPreferences.getInstance(); |  | ||||||
|     String domain = prefs.getString('hassio-domain'); |  | ||||||
|     String port = prefs.getString('hassio-port'); |  | ||||||
|     hostname = "$domain:$port"; |  | ||||||
|     _webSocketAPIEndpoint = "${prefs.getString('hassio-protocol')}://$domain:$port/api/websocket"; |  | ||||||
|     httpWebHost = "${prefs.getString('hassio-res-protocol')}://$domain:$port"; |  | ||||||
|     _token = prefs.getString('hassio-token'); |  | ||||||
|     final storage = new FlutterSecureStorage(); |  | ||||||
|     try { |  | ||||||
|       _token = await storage.read(key: "hacl_llt"); |  | ||||||
|     } catch (e) { |  | ||||||
|       Logger.e("Cannt read secure storage. Need to relogin."); |  | ||||||
|       _token = null; |  | ||||||
|       await storage.delete(key: "hacl_llt"); |  | ||||||
|     } |  | ||||||
|     _useLovelace = prefs.getBool('use-lovelace') ?? true; |  | ||||||
|     if ((domain == null) || (port == null) || |  | ||||||
|         (domain.length == 0) || (port.length == 0)) { |  | ||||||
|       throw("Check connection settings"); |  | ||||||
|     } else { |  | ||||||
|       isSettingsLoaded = true; |  | ||||||
|       oauthUrl = "$httpWebHost/auth/authorize?client_id=${Uri.encodeComponent('http://ha-client.homemade.systems/')}&redirect_uri=${Uri.encodeComponent('http://ha-client.homemade.systems/service/auth_callback.html')}"; |  | ||||||
|       entities = EntityCollection(httpWebHost); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /*void updateSettings(String url, String password, bool useLovelace) { |  | ||||||
|     _webSocketAPIEndpoint = url; |  | ||||||
|     _password = password; |  | ||||||
|     _useLovelace = useLovelace; |  | ||||||
|     Logger.d( "Use lovelace is $_useLovelace"); |  | ||||||
|   }*/ |  | ||||||
|  |  | ||||||
|   Future fetch() { |   Future fetch() { | ||||||
|     //return _connection().then((_) => _getData()); |     if (_fetchCompleter != null && !_fetchCompleter.isCompleted) { | ||||||
|  |       Logger.w("Previous data fetch is not completed yet"); | ||||||
|     if ((_fetchCompleter != null) && (!_fetchCompleter.isCompleted)) { |       return _fetchCompleter.future; | ||||||
|       Logger.w("Previous fetch is not complited"); |  | ||||||
|     } else { |  | ||||||
|       Logger.d("Fetching..."); |  | ||||||
|       _fetchCompleter = new Completer(); |  | ||||||
|       _fetchTimer?.cancel(); |  | ||||||
|       _fetchTimer = Timer(fetchTimeout, () { |  | ||||||
|         Logger.e( "Data fetching timeout"); |  | ||||||
|         disconnect().then((_) { |  | ||||||
|           _completeFetching({ |  | ||||||
|             "errorCode": 9, |  | ||||||
|             "errorMessage": "Couldn't get data from server" |  | ||||||
|           }); |  | ||||||
|         }); |  | ||||||
|       }); |  | ||||||
|       _connection().then((r) { |  | ||||||
|         _getData(); |  | ||||||
|       }).catchError((e) { |  | ||||||
|         _completeFetching(e); |  | ||||||
|       }); |  | ||||||
|     } |     } | ||||||
|     return _fetchCompleter.future; |     _fetchCompleter = Completer(); | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future disconnect() async { |  | ||||||
|     Logger.d( "Socket disconnecting..."); |  | ||||||
|     await _socketSubscription?.cancel(); |  | ||||||
|     await _hassioChannel?.sink?.close()?.timeout(Duration(seconds: 4), |  | ||||||
|         onTimeout: () => Logger.d( "Socket sink close timeout") |  | ||||||
|     ); |  | ||||||
|     _hassioChannel = null; |  | ||||||
|     Logger.d( "..Disconnected"); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future _connection() { |  | ||||||
|     if ((_connectionCompleter != null) && (!_connectionCompleter.isCompleted)) { |  | ||||||
|       Logger.d("Previous connection is not complited"); |  | ||||||
|     } else { |  | ||||||
|       if ((_hassioChannel == null) || (_hassioChannel.closeCode != null)) { |  | ||||||
|         _connectionTimer?.cancel(); |  | ||||||
|         _connectionCompleter = new Completer(); |  | ||||||
|         autoReconnect = false; |  | ||||||
|         disconnect().then((_){ |  | ||||||
|           Logger.d( "Socket connecting..."); |  | ||||||
|           _connectionTimer = Timer(connectTimeout, () { |  | ||||||
|             Logger.e( "Socket connection timeout"); |  | ||||||
|             _handleSocketError(null); |  | ||||||
|           }); |  | ||||||
|           _socketSubscription?.cancel(); |  | ||||||
|           _hassioChannel = IOWebSocketChannel.connect( |  | ||||||
|               _webSocketAPIEndpoint, pingInterval: Duration(seconds: 30)); |  | ||||||
|           _socketSubscription = _hassioChannel.stream.listen( |  | ||||||
|                   (message) => _handleMessage(message), |  | ||||||
|               cancelOnError: true, |  | ||||||
|               onDone: () => _handleSocketClose(), |  | ||||||
|               onError: (e) => _handleSocketError(e) |  | ||||||
|           ); |  | ||||||
|         }); |  | ||||||
|       } else { |  | ||||||
|         _completeConnecting(null); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|     return _connectionCompleter.future; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void _handleSocketClose() { |  | ||||||
|     Logger.d("Socket disconnected. Automatic reconnect is $autoReconnect"); |  | ||||||
|     if (autoReconnect) { |  | ||||||
|       _reconnect(); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void _handleSocketError(e) { |  | ||||||
|     Logger.e("Socket stream Error: $e"); |  | ||||||
|     Logger.d("Automatic reconnect is $autoReconnect"); |  | ||||||
|     if (autoReconnect) { |  | ||||||
|       _reconnect(); |  | ||||||
|     } else { |  | ||||||
|       disconnect().then((_) { |  | ||||||
|         _completeConnecting({ |  | ||||||
|           "errorCode": 1, |  | ||||||
|           "errorMessage": "Couldn't connect to Home Assistant. Check network connection or connection settings." |  | ||||||
|         }); |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void _reconnect() { |  | ||||||
|     disconnect().then((_) { |  | ||||||
|       _connection().catchError((e){ |  | ||||||
|         _completeConnecting(e); |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   _getData() async { |  | ||||||
|     List<Future> futures = []; |     List<Future> futures = []; | ||||||
|     futures.add(_getStates()); |     futures.add(_getStates()); | ||||||
|     if (_useLovelace) { |     if (_useLovelace) { | ||||||
|       futures.add(_getLovelace()); |       futures.add(_getLovelace()); | ||||||
|     } |     } | ||||||
|     if (_token == null && _tempToken != null) { |  | ||||||
|       futures.add(_getLongLivedToken()); |  | ||||||
|     } |  | ||||||
|     futures.add(_getConfig()); |     futures.add(_getConfig()); | ||||||
|     futures.add(_getServices()); |     futures.add(_getServices()); | ||||||
|     futures.add(_getUserInfo()); |     futures.add(_getUserInfo()); | ||||||
|     futures.add(_getPanels()); |     futures.add(_getPanels()); | ||||||
|     futures.add( |     Future.wait(futures).then((_) { | ||||||
|         _sendSocketMessage( |  | ||||||
|           type: "subscribe_events", |  | ||||||
|           additionalData: {"event_type": "state_changed"}, |  | ||||||
|         ) |  | ||||||
|     ); |  | ||||||
|     await Future.wait(futures).then((_) { |  | ||||||
|       _createUI(); |       _createUI(); | ||||||
|       _completeFetching(null); |       _fetchCompleter.complete(); | ||||||
|     }).catchError((e) { |     }).catchError((e) { | ||||||
|       disconnect().then((_) => |       _fetchCompleter.completeError(e); | ||||||
|           _completeFetching(e) |  | ||||||
|       ); |  | ||||||
|     }); |     }); | ||||||
|   } |     return _fetchCompleter.future; | ||||||
|  |  | ||||||
|   void _completeFetching(error) { |  | ||||||
|     _fetchTimer?.cancel(); |  | ||||||
|     _completeConnecting(error); |  | ||||||
|     if (!_fetchCompleter.isCompleted) { |  | ||||||
|       if (error != null) { |  | ||||||
|         _fetchCompleter.completeError(error); |  | ||||||
|       } else { |  | ||||||
|         autoReconnect = true; |  | ||||||
|         Logger.d( "Fetch complete successful"); |  | ||||||
|         _fetchCompleter.complete(); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void _completeConnecting(error) { |  | ||||||
|     _connectionTimer?.cancel(); |  | ||||||
|     if (!_connectionCompleter.isCompleted) { |  | ||||||
|       if (error != null) { |  | ||||||
|         _connectionCompleter.completeError(error); |  | ||||||
|       } else { |  | ||||||
|         _connectionCompleter.complete(); |  | ||||||
|       } |  | ||||||
|     } else if (error != null) { |  | ||||||
|       if (error is Error) { |  | ||||||
|         eventBus.fire(ShowErrorEvent(error.toString(), 12)); |  | ||||||
|       } else { |  | ||||||
|         eventBus.fire(ShowErrorEvent(error["errorMessage"], error["errorCode"])); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   _handleMessage(String message) { |  | ||||||
|     var data = json.decode(message); |  | ||||||
|     if (data["type"] == "auth_required") { |  | ||||||
|       Logger.d("[Received] <== ${data.toString()}"); |  | ||||||
|       _sendAuth(); |  | ||||||
|     } else if (data["type"] == "auth_ok") { |  | ||||||
|       Logger.d("[Received] <== ${data.toString()}"); |  | ||||||
|       _completeConnecting(null); |  | ||||||
|     } else if (data["type"] == "auth_invalid") { |  | ||||||
|       Logger.d("[Received] <== ${data.toString()}"); |  | ||||||
|       logout(); |  | ||||||
|       _completeConnecting({"errorCode": 62, "errorMessage": "${data["message"]}"}); |  | ||||||
|     } else if (data["type"] == "result") { |  | ||||||
|       if (data["success"]) { |  | ||||||
|         Logger.d("[Received] <== Request id ${data['id']} was successful"); |  | ||||||
|         _messageResolver[data["id"]]?.complete(data["result"]); |  | ||||||
|       } else { |  | ||||||
|         Logger.e("[Received] <== Error received on request id ${data['id']}: ${data['error']}"); |  | ||||||
|         _messageResolver[data["id"]]?.completeError(data['error']["message"]); |  | ||||||
|       } |  | ||||||
|       _messageResolver.remove(data["id"]); |  | ||||||
|     } else if (data["type"] == "event") { |  | ||||||
|       if ((data["event"] != null) && (data["event"]["event_type"] == "state_changed")) { |  | ||||||
|         Logger.d("[Received] <== ${data['type']}.${data["event"]["event_type"]}: ${data["event"]["data"]["entity_id"]}"); |  | ||||||
|         _handleEntityStateChange(data["event"]["data"]); |  | ||||||
|       } else if (data["event"] != null) { |  | ||||||
|         Logger.w("Unhandled event type: ${data["event"]["event_type"]}"); |  | ||||||
|       } else { |  | ||||||
|         Logger.e("Event is null: $message"); |  | ||||||
|       } |  | ||||||
|     } else { |  | ||||||
|       Logger.d("[Received] <== ${data.toString()}"); |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future logout() async { |   Future logout() async { | ||||||
|     Logger.d("Logging out..."); |     Logger.d("Logging out..."); | ||||||
|     _token = null; |     await connection.logout().then((_) { | ||||||
|     _tempToken = null; |       ui?.clear(); | ||||||
|     final storage = new FlutterSecureStorage(); |       entities?.clear(); | ||||||
|     await storage.delete(key: "hacl_llt"); |     }); | ||||||
|     ui?.clear(); |  | ||||||
|     entities?.clear(); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future _getConfig() async { |   Future _getConfig() async { | ||||||
|     return _sendSocketMessage(type: "get_config").then((data) { |     await connection.sendSocketMessage(type: "get_config").then((data) { | ||||||
|       _instanceConfig = Map.from(data); |       _instanceConfig = Map.from(data); | ||||||
|     }).catchError((e) { |     }).catchError((e) { | ||||||
|       throw {"errorCode": 1, "errorMessage": "Error getting config: $e"}; |       throw {"errorCode": 1, "errorMessage": "Error getting config: $e"}; | ||||||
| @@ -303,48 +100,35 @@ class HomeAssistant { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future _getStates() async { |   Future _getStates() async { | ||||||
|     return _sendSocketMessage(type: "get_states").then( |     await connection.sendSocketMessage(type: "get_states").then( | ||||||
|             (data) => entities.parse(data) |             (data) => entities.parse(data) | ||||||
|     ).catchError((e) { |     ).catchError((e) { | ||||||
|       throw {"errorCode": 1, "errorMessage": "Error getting states: $e"}; |       throw {"errorCode": 1, "errorMessage": "Error getting states: $e"}; | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future _getLongLivedToken() async { |  | ||||||
|     return _sendSocketMessage(type: "auth/long_lived_access_token", additionalData: {"client_name": "HA Client app ${DateTime.now().millisecondsSinceEpoch}", "lifespan": 365}).then((data) { |  | ||||||
|       Logger.d("Got long-lived token."); |  | ||||||
|       _token = data; |  | ||||||
|       _tempToken = null; |  | ||||||
|       final storage = new FlutterSecureStorage(); |  | ||||||
|       storage.write(key: "hacl_llt", value: _token); |  | ||||||
|     }).catchError((e) { |  | ||||||
|       logout(); |  | ||||||
|       throw {"errorCode": 63, "errorMessage": "Authentication error: $e"}; |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future _getLovelace() async { |   Future _getLovelace() async { | ||||||
|     return _sendSocketMessage(type: "lovelace/config").then((data) => _rawLovelaceData = data).catchError((e) { |     await connection.sendSocketMessage(type: "lovelace/config").then((data) => _rawLovelaceData = data).catchError((e) { | ||||||
|       throw {"errorCode": 1, "errorMessage": "Error getting lovelace config: $e"}; |       throw {"errorCode": 1, "errorMessage": "Error getting lovelace config: $e"}; | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future _getUserInfo() async { |   Future _getUserInfo() async { | ||||||
|     _userName = null; |     _userName = null; | ||||||
|     return _sendSocketMessage(type: "auth/current_user").then((data) => _userName = data["name"]).catchError((e) { |     await connection.sendSocketMessage(type: "auth/current_user").then((data) => _userName = data["name"]).catchError((e) { | ||||||
|       Logger.w("Can't get user info: ${e}"); |       Logger.w("Can't get user info: ${e}"); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future _getServices() async { |   Future _getServices() async { | ||||||
|     return _sendSocketMessage(type: "get_services").then((data) => Logger.d("Services received")).catchError((e) { |     await connection.sendSocketMessage(type: "get_services").then((data) => Logger.d("Services received")).catchError((e) { | ||||||
|       Logger.w("Can't get services: ${e}"); |       Logger.w("Can't get services: ${e}"); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future _getPanels() async { |   Future _getPanels() async { | ||||||
|     panels.clear(); |     panels.clear(); | ||||||
|     return _sendSocketMessage(type: "get_panels").then((data) { |     await connection.sendSocketMessage(type: "get_panels").then((data) { | ||||||
|       data.forEach((k,v) { |       data.forEach((k,v) { | ||||||
|         String title = v['title'] == null ? "${k[0].toUpperCase()}${k.substring(1)}" : "${v['title'][0].toUpperCase()}${v['title'].substring(1)}"; |         String title = v['title'] == null ? "${k[0].toUpperCase()}${k.substring(1)}" : "${v['title'][0].toUpperCase()}${v['title'].substring(1)}"; | ||||||
|         panels.add(Panel( |         panels.add(Panel( | ||||||
| @@ -359,138 +143,7 @@ class HomeAssistant { | |||||||
|       }); |       }); | ||||||
|     }).catchError((e) { |     }).catchError((e) { | ||||||
|       throw {"errorCode": 1, "errorMessage": "Error getting panels list: $e"}; |       throw {"errorCode": 1, "errorMessage": "Error getting panels list: $e"}; | ||||||
|     });; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   _incrementMessageId() { |  | ||||||
|     _currentMessageId += 1; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void _sendAuth() { |  | ||||||
|     if (_token != null) { |  | ||||||
|       Logger.d( "Long leaved token exist"); |  | ||||||
|       Logger.d( "[Sending] ==> auth request"); |  | ||||||
|       _hassioChannel.sink.add('{"type": "auth","access_token": "$_token"}'); |  | ||||||
|     } else if (_tempToken == null) { |  | ||||||
|       Logger.d( "No long leaved token. Need to authenticate."); |  | ||||||
|       final flutterWebviewPlugin = new FlutterWebviewPlugin(); |  | ||||||
|       flutterWebviewPlugin.onUrlChanged.listen((String url) { |  | ||||||
|         if (url.startsWith("http://ha-client.homemade.systems/service/auth_callback.html")) { |  | ||||||
|           String authCode = url.split("=")[1]; |  | ||||||
|           Logger.d("We have auth code. Getting temporary access token..."); |  | ||||||
|           sendHTTPPost( |  | ||||||
|             endPoint: "/auth/token", |  | ||||||
|             contentType: "application/x-www-form-urlencoded", |  | ||||||
|             includeAuthHeader: false, |  | ||||||
|             data: "grant_type=authorization_code&code=$authCode&client_id=${Uri.encodeComponent('http://ha-client.homemade.systems/')}" |  | ||||||
|           ).then((response) { |  | ||||||
|             Logger.d("Gottemp token"); |  | ||||||
|             _tempToken = json.decode(response)['access_token']; |  | ||||||
|             Logger.d("Closing webview..."); |  | ||||||
|             flutterWebviewPlugin.close(); |  | ||||||
|             Logger.d("Firing event to reload UI"); |  | ||||||
|             eventBus.fire(ReloadUIEvent()); |  | ||||||
|           }).catchError((e) { |  | ||||||
|             logout(); |  | ||||||
|             disconnect(); |  | ||||||
|             flutterWebviewPlugin.close(); |  | ||||||
|             _completeFetching({"errorCode": 61, "errorMessage": "Error getting temp token"}); |  | ||||||
|             Logger.e("Error getting temp token: ${e.toString()}"); |  | ||||||
|           }); |  | ||||||
|         } |  | ||||||
|       }); |  | ||||||
|       disconnect(); |  | ||||||
|       _completeFetching({"errorCode": 60, "errorMessage": "Not authenticated"}); |  | ||||||
|       _requestOAuth(); |  | ||||||
|     } else if (_tempToken != null) { |  | ||||||
|       Logger.d("We have temp token. Login..."); |  | ||||||
|       _hassioChannel.sink.add('{"type": "auth","access_token": "$_tempToken"}'); |  | ||||||
|     } else { |  | ||||||
|       Logger.e("General login error"); |  | ||||||
|       logout(); |  | ||||||
|       disconnect(); |  | ||||||
|       _completeFetching({"errorCode": 61, "errorMessage": "General login error"}); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void _requestOAuth() { |  | ||||||
|     Logger.d("OAuth url: $oauthUrl"); |  | ||||||
|     eventBus.fire(StartAuthEvent(oauthUrl)); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future _sendSocketMessage({String type, Map additionalData, bool noId: false}) { |  | ||||||
|     Completer _completer = Completer(); |  | ||||||
|     Map dataObject = {"type": "$type"}; |  | ||||||
|     if (!noId) { |  | ||||||
|       _incrementMessageId(); |  | ||||||
|       dataObject["id"] = _currentMessageId; |  | ||||||
|     } |  | ||||||
|     if (additionalData != null) { |  | ||||||
|       dataObject.addAll(additionalData); |  | ||||||
|     } |  | ||||||
|     _messageResolver[_currentMessageId] = _completer; |  | ||||||
|     _rawSend(json.encode(dataObject), false); |  | ||||||
|     return _completer.future; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   _rawSend(String message, bool queued) { |  | ||||||
|     var sendCompleter = Completer(); |  | ||||||
|     if (queued) { |  | ||||||
|       _messageQueue.add(message); |  | ||||||
|     } |  | ||||||
|     _connection().then((r) { |  | ||||||
|       _messageQueue.getActualMessages().forEach((message){ |  | ||||||
|         Logger.d( "[Sending queued] ==> $message"); |  | ||||||
|         _hassioChannel.sink.add(message); |  | ||||||
|       }); |  | ||||||
|       if (!queued) { |  | ||||||
|         Logger.d( "[Sending] ==> $message"); |  | ||||||
|         _hassioChannel.sink.add(message); |  | ||||||
|       } |  | ||||||
|       sendCompleter.complete(); |  | ||||||
|     }).catchError((e){ |  | ||||||
|       sendCompleter.completeError(e); |  | ||||||
|     }); |     }); | ||||||
|     return sendCompleter.future; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future callService(String domain, String service, String entityId, Map<String, dynamic> additionalParams) { |  | ||||||
|     _incrementMessageId(); |  | ||||||
|     String message = ""; |  | ||||||
|     if (entityId != null) { |  | ||||||
|       message = '{"id": $_currentMessageId, "type": "call_service", "domain": "$domain", "service": "$service", "service_data": {"entity_id": "$entityId"'; |  | ||||||
|       if (additionalParams != null) { |  | ||||||
|         additionalParams.forEach((name, value) { |  | ||||||
|           if ((value is double) || (value is int) || (value is List)) { |  | ||||||
|             message += ', "$name" : $value'; |  | ||||||
|           } else { |  | ||||||
|             message += ', "$name" : "$value"'; |  | ||||||
|           } |  | ||||||
|         }); |  | ||||||
|       } |  | ||||||
|       message += '}}'; |  | ||||||
|     } else { |  | ||||||
|       message = '{"id": $_currentMessageId, "type": "call_service", "domain": "$domain", "service": "$service"'; |  | ||||||
|       if (additionalParams != null && additionalParams.isNotEmpty) { |  | ||||||
|         message += ', "service_data": {'; |  | ||||||
|         bool first = true; |  | ||||||
|         additionalParams.forEach((name, value) { |  | ||||||
|           if (!first) { |  | ||||||
|             message += ', '; |  | ||||||
|           } |  | ||||||
|           if ((value is double) || (value is int) || (value is List)) { |  | ||||||
|             message += '"$name" : $value'; |  | ||||||
|           } else { |  | ||||||
|             message += '"$name" : "$value"'; |  | ||||||
|           } |  | ||||||
|           first = false; |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         message += '}'; |  | ||||||
|       } |  | ||||||
|       message += '}'; |  | ||||||
|     } |  | ||||||
|     return _rawSend(message, true); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _handleEntityStateChange(Map eventData) { |   void _handleEntityStateChange(Map eventData) { | ||||||
| @@ -699,57 +352,9 @@ class HomeAssistant { | |||||||
|   Widget buildViews(BuildContext context, TabController tabController) { |   Widget buildViews(BuildContext context, TabController tabController) { | ||||||
|     return ui.build(context, tabController); |     return ui.build(context, tabController); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<List> getHistory(String entityId) async { |  | ||||||
|     DateTime now = DateTime.now(); |  | ||||||
|     //String endTime = formatDate(now, [yyyy, '-', mm, '-', dd, 'T', HH, ':', nn, ':', ss, z]); |  | ||||||
|     String startTime = formatDate(now.subtract(Duration(hours: 24)), [yyyy, '-', mm, '-', dd, 'T', HH, ':', nn, ':', ss, z]); |  | ||||||
|     String url = "$httpWebHost/api/history/period/$startTime?&filter_entity_id=$entityId"; |  | ||||||
|     Logger.d("[Sending] ==> $url"); |  | ||||||
|     http.Response historyResponse; |  | ||||||
|     historyResponse = await http.get(url, headers: { |  | ||||||
|         "authorization": "Bearer $_token", |  | ||||||
|         "Content-Type": "application/json" |  | ||||||
|     }); |  | ||||||
|     var history = json.decode(historyResponse.body); |  | ||||||
|     if (history is List) { |  | ||||||
|       Logger.d( "[Received] <== ${history.first.length} history recors"); |  | ||||||
|       return history; |  | ||||||
|     } else { |  | ||||||
|       return []; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future sendHTTPPost({String endPoint, String data, String contentType: "application/json", bool includeAuthHeader: true}) async { |  | ||||||
|     Completer completer = Completer(); |  | ||||||
|     String url = "$httpWebHost$endPoint"; |  | ||||||
|     Logger.d("[Sending] ==> $url"); |  | ||||||
|     Map<String, String> headers = {}; |  | ||||||
|     if (contentType != null) { |  | ||||||
|       headers["Content-Type"] = contentType; |  | ||||||
|     } |  | ||||||
|     if (includeAuthHeader) { |  | ||||||
|       headers["authorization"] = "Bearer $_token"; |  | ||||||
|     } |  | ||||||
|     http.post( |  | ||||||
|       url, |  | ||||||
|       headers: headers, |  | ||||||
|       body: data |  | ||||||
|     ).then((response) { |  | ||||||
|       Logger.d("[Received] <== ${response.statusCode}, ${response.body}"); |  | ||||||
|       if (response.statusCode == 200) { |  | ||||||
|         completer.complete(response.body); |  | ||||||
|       } else { |  | ||||||
|         completer.completeError({"code": response.statusCode, "message": "${response.body}"}); |  | ||||||
|       } |  | ||||||
|     }).catchError((e) { |  | ||||||
|       completer.completeError(e); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     return completer.future; |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /* | ||||||
| class SendMessageQueue { | class SendMessageQueue { | ||||||
|   int _messageTimeout; |   int _messageTimeout; | ||||||
|   List<HAMessage> _queue = []; |   List<HAMessage> _queue = []; | ||||||
| @@ -788,4 +393,4 @@ class HAMessage { | |||||||
|   bool isExpired() { |   bool isExpired() { | ||||||
|     return DateTime.now().difference(_timeStamp).inSeconds > _messageTimeout; |     return DateTime.now().difference(_timeStamp).inSeconds > _messageTimeout; | ||||||
|   } |   } | ||||||
| } | }*/ | ||||||
|   | |||||||
							
								
								
									
										148
									
								
								lib/main.dart
									
									
									
									
									
								
							
							
						
						
									
										148
									
								
								lib/main.dart
									
									
									
									
									
								
							| @@ -90,6 +90,8 @@ part 'entity.page.dart'; | |||||||
| part 'utils.class.dart'; | part 'utils.class.dart'; | ||||||
| part 'mdi.class.dart'; | part 'mdi.class.dart'; | ||||||
| part 'entity_collection.class.dart'; | part 'entity_collection.class.dart'; | ||||||
|  | part 'auth_manager.class.dart'; | ||||||
|  | part 'connection.class.dart'; | ||||||
| part 'ui_class/ui.dart'; | part 'ui_class/ui.dart'; | ||||||
| part 'ui_class/view.class.dart'; | part 'ui_class/view.class.dart'; | ||||||
| part 'ui_class/card.class.dart'; | part 'ui_class/card.class.dart'; | ||||||
| @@ -178,9 +180,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|     _settingsSubscription = eventBus.on<SettingsChangedEvent>().listen((event) { |     _settingsSubscription = eventBus.on<SettingsChangedEvent>().listen((event) { | ||||||
|       Logger.d("Settings change event: reconnect=${event.reconnect}"); |       Logger.d("Settings change event: reconnect=${event.reconnect}"); | ||||||
|       if (event.reconnect) { |       if (event.reconnect) { | ||||||
|         widget.homeAssistant.disconnect().then((_){ |         _reLoad(); | ||||||
|           _initialLoad(); |  | ||||||
|         }); |  | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
| @@ -188,19 +188,45 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _initialLoad() { |   void _initialLoad() { | ||||||
|     widget.homeAssistant.loadConnectionSettings().then((_){ |     _showInfoBottomBar(progress: true,); | ||||||
|       _subscribe(); |     _subscribe(); | ||||||
|       _refreshData(); |     widget.homeAssistant.init().then((_){ | ||||||
|     }, onError: (_) { |       _fetchData(); | ||||||
|       _showErrorBottomBar(message: _, errorCode: 5); |     }, onError: (e) { | ||||||
|  |       _setErrorState(e); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   void _reLoad() { | ||||||
|  |     _hideBottomBar(); | ||||||
|  |     _showInfoBottomBar(progress: true,); | ||||||
|  |     widget.homeAssistant.init().then((_){ | ||||||
|  |       _fetchData(); | ||||||
|  |     }, onError: (e) { | ||||||
|  |       _setErrorState(e); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   _fetchData() async { | ||||||
|  |     await widget.homeAssistant.fetch().then((_) { | ||||||
|  |       _hideBottomBar(); | ||||||
|  |       int currentViewCount = widget.homeAssistant.ui?.views?.length ?? 0; | ||||||
|  |       if (_previousViewCount != currentViewCount) { | ||||||
|  |         Logger.d("Views count changed ($_previousViewCount->$currentViewCount). Creating new tabs controller."); | ||||||
|  |         _viewsTabController = TabController(vsync: this, length: currentViewCount); | ||||||
|  |         _previousViewCount = currentViewCount; | ||||||
|  |       } | ||||||
|  |     }).catchError((e) { | ||||||
|  |       _setErrorState(e); | ||||||
|  |     }); | ||||||
|  |     eventBus.fire(RefreshDataFinishedEvent()); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void didChangeAppLifecycleState(AppLifecycleState state) { |   void didChangeAppLifecycleState(AppLifecycleState state) { | ||||||
|     //Logger.d("$state"); |     Logger.d("$state"); | ||||||
|     if (state == AppLifecycleState.resumed && widget.homeAssistant.isSettingsLoaded) { |     if (state == AppLifecycleState.resumed) { | ||||||
|       _refreshData(); |       _reLoad(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -209,7 +235,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|       _stateSubscription = eventBus.on<StateChangedEvent>().listen((event) { |       _stateSubscription = eventBus.on<StateChangedEvent>().listen((event) { | ||||||
|         if (event.needToRebuildUI) { |         if (event.needToRebuildUI) { | ||||||
|           Logger.d("New entity. Need to rebuild UI"); |           Logger.d("New entity. Need to rebuild UI"); | ||||||
|           _refreshData(); |           _reLoad(); | ||||||
|         } else { |         } else { | ||||||
|           setState(() {}); |           setState(() {}); | ||||||
|         } |         } | ||||||
| @@ -217,7 +243,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|     } |     } | ||||||
|     if (_reloadUISubscription == null) { |     if (_reloadUISubscription == null) { | ||||||
|       _reloadUISubscription = eventBus.on<ReloadUIEvent>().listen((event){ |       _reloadUISubscription = eventBus.on<ReloadUIEvent>().listen((event){ | ||||||
|         _refreshData(); |         _reLoad(); | ||||||
|       }); |       }); | ||||||
|     } |     } | ||||||
|     if (_serviceCallSubscription == null) { |     if (_serviceCallSubscription == null) { | ||||||
| @@ -274,34 +300,19 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|         context, |         context, | ||||||
|         MaterialPageRoute( |         MaterialPageRoute( | ||||||
|           builder: (context) => WebviewScaffold( |           builder: (context) => WebviewScaffold( | ||||||
|             url: "${widget.homeAssistant.oauthUrl}", |             url: "${widget.homeAssistant.connection.oauthUrl}", | ||||||
|             appBar: new AppBar( |             appBar: new AppBar( | ||||||
|               title: new Text("Login"), |               leading: IconButton( | ||||||
|  |                   icon: Icon(Icons.help), | ||||||
|  |                   onPressed: () => HAUtils.launchURLInCustomTab(context, "http://ha-client.homemade.systems/docs#authentication") | ||||||
|  |               ), | ||||||
|  |               title: new Text("Login to your Home Assistant"), | ||||||
|             ), |             ), | ||||||
|           ), |           ), | ||||||
|         ) |         ) | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   _refreshData() async { |  | ||||||
|     //widget.homeAssistant.updateSettings(_webSocketApiEndpoint, _password, _useLovelaceUI); |  | ||||||
|     _hideBottomBar(); |  | ||||||
|     _showInfoBottomBar(progress: true,); |  | ||||||
|     Logger.d("Calling fetch()"); |  | ||||||
|     await widget.homeAssistant.fetch().then((result) { |  | ||||||
|       _hideBottomBar(); |  | ||||||
|       int currentViewCount = widget.homeAssistant.ui?.views?.length ?? 0; |  | ||||||
|       if (_previousViewCount != currentViewCount) { |  | ||||||
|         Logger.d("Views count changed ($_previousViewCount->$currentViewCount). Creating new tabs controller."); |  | ||||||
|         _viewsTabController = TabController(vsync: this, length: currentViewCount); |  | ||||||
|         _previousViewCount = currentViewCount; |  | ||||||
|       } |  | ||||||
|     }).catchError((e) { |  | ||||||
|       _setErrorState(e); |  | ||||||
|     }); |  | ||||||
|     eventBus.fire(RefreshDataFinishedEvent()); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   _setErrorState(e) { |   _setErrorState(e) { | ||||||
|     if (e is Error) { |     if (e is Error) { | ||||||
|       Logger.e(e.toString()); |       Logger.e(e.toString()); | ||||||
| @@ -318,12 +329,12 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _callService(String domain, String service, String entityId, Map<String, dynamic> additionalParams) { |   void _callService(String domain, String service, String entityId, Map additionalParams) { | ||||||
|     _showInfoBottomBar( |     _showInfoBottomBar( | ||||||
|       message: "Calling $domain.$service", |       message: "Calling $domain.$service", | ||||||
|       duration: Duration(seconds: 3) |       duration: Duration(seconds: 3) | ||||||
|     ); |     ); | ||||||
|     widget.homeAssistant.callService(domain, service, entityId, additionalParams).catchError((e) => _setErrorState(e)); |     widget.homeAssistant.connection.callService(domain: domain, service: service, entityId: entityId, additionalServiceData: additionalParams).catchError((e) => _setErrorState(e)); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void _showEntityPage(String entityId) { |   void _showEntityPage(String entityId) { | ||||||
| @@ -381,15 +392,14 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|           } |           } | ||||||
|         }); |         }); | ||||||
|       } |       } | ||||||
|       if (widget.homeAssistant.isSettingsLoaded) { |       //TODO check for loaded | ||||||
|         menuItems.add( |       menuItems.add( | ||||||
|           new ListTile( |           new ListTile( | ||||||
|             leading: Icon(MaterialDesignIcons.getIconDataFromIconName("mdi:home-assistant")), |             leading: Icon(MaterialDesignIcons.getIconDataFromIconName("mdi:home-assistant")), | ||||||
|             title: Text("Open Web UI"), |             title: Text("Open Web UI"), | ||||||
|             onTap: () => HAUtils.launchURL(widget.homeAssistant.httpWebHost), |             onTap: () => HAUtils.launchURL(widget.homeAssistant.connection.httpWebHost), | ||||||
|           ) |           ) | ||||||
|         ); |       ); | ||||||
|       } |  | ||||||
|       menuItems.addAll([ |       menuItems.addAll([ | ||||||
|         Divider(), |         Divider(), | ||||||
|         ListTile( |         ListTile( | ||||||
| @@ -500,7 +510,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|                 child: Text("Retry", style: textStyle), |                 child: Text("Retry", style: textStyle), | ||||||
|                 onPressed: () { |                 onPressed: () { | ||||||
|                   //_scaffoldKey?.currentState?.hideCurrentSnackBar(); |                   //_scaffoldKey?.currentState?.hideCurrentSnackBar(); | ||||||
|                   _refreshData(); |                   _reLoad(); | ||||||
|                 }, |                 }, | ||||||
|             ); |             ); | ||||||
|             break; |             break; | ||||||
| @@ -522,7 +532,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|           _bottomBarAction = FlatButton( |           _bottomBarAction = FlatButton( | ||||||
|               child: Text("Login", style: textStyle), |               child: Text("Login", style: textStyle), | ||||||
|             onPressed: () { |             onPressed: () { | ||||||
|               _refreshData(); |               _reLoad(); | ||||||
|             }, |             }, | ||||||
|           ); |           ); | ||||||
|           break; |           break; | ||||||
| @@ -533,7 +543,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|           _bottomBarAction = FlatButton( |           _bottomBarAction = FlatButton( | ||||||
|             child: Text("Try again", style: textStyle), |             child: Text("Try again", style: textStyle), | ||||||
|             onPressed: () { |             onPressed: () { | ||||||
|               _refreshData(); |               _reLoad(); | ||||||
|             }, |             }, | ||||||
|           ); |           ); | ||||||
|           break; |           break; | ||||||
| @@ -543,7 +553,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|           _bottomBarAction = FlatButton( |           _bottomBarAction = FlatButton( | ||||||
|             child: Text("Login again", style: textStyle), |             child: Text("Login again", style: textStyle), | ||||||
|             onPressed: () { |             onPressed: () { | ||||||
|               _refreshData(); |               _reLoad(); | ||||||
|             }, |             }, | ||||||
|           ); |           ); | ||||||
|           break; |           break; | ||||||
| @@ -554,31 +564,26 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|               child: Text("Refresh", style: textStyle), |               child: Text("Refresh", style: textStyle), | ||||||
|             onPressed: () { |             onPressed: () { | ||||||
|               //_scaffoldKey?.currentState?.hideCurrentSnackBar(); |               //_scaffoldKey?.currentState?.hideCurrentSnackBar(); | ||||||
|               _refreshData(); |               _reLoad(); | ||||||
|             }, |             }, | ||||||
|           ); |           ); | ||||||
|           break; |           break; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         case 82: | ||||||
|  |         case 81: | ||||||
|         case 8: { |         case 8: { | ||||||
|           _bottomBarAction = FlatButton( |           _bottomBarAction = FlatButton( | ||||||
|               child: Text("Reconnect", style: textStyle), |               child: Text("Reconnect", style: textStyle), | ||||||
|             onPressed: () { |             onPressed: () { | ||||||
|               //_scaffoldKey?.currentState?.hideCurrentSnackBar(); |               _reLoad(); | ||||||
|               _refreshData(); |  | ||||||
|             }, |             }, | ||||||
|           ); |           ); | ||||||
|           break; |           break; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         default: { |         default: { | ||||||
|           _bottomBarAction = FlatButton( |           _bottomBarAction = Container(width: 0.0, height: 0.0,); | ||||||
|               child: Text("Reload", style: textStyle), |  | ||||||
|             onPressed: () { |  | ||||||
|               //_scaffoldKey?.currentState?.hideCurrentSnackBar(); |  | ||||||
|               _refreshData(); |  | ||||||
|             }, |  | ||||||
|           ); |  | ||||||
|           break; |           break; | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| @@ -593,22 +598,16 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|  |  | ||||||
|   Widget _buildScaffoldBody(bool empty) { |   Widget _buildScaffoldBody(bool empty) { | ||||||
|     List<PopupMenuItem<String>> popupMenuItems = []; |     List<PopupMenuItem<String>> popupMenuItems = []; | ||||||
|     if (widget.homeAssistant.isAuthenticated) { |     popupMenuItems.add(PopupMenuItem<String>( | ||||||
|       popupMenuItems.addAll([ |       child: new Text("Reload"), | ||||||
|           PopupMenuItem<String>( |       value: "reload", | ||||||
|             child: new Text("Reload"), |     )); | ||||||
|             value: "reload", |     if (widget.homeAssistant.connection.isAuthenticated) { | ||||||
|           ), |       popupMenuItems.add( | ||||||
|           PopupMenuItem<String>( |           PopupMenuItem<String>( | ||||||
|             child: new Text("Logout"), |             child: new Text("Logout"), | ||||||
|             value: "logout", |             value: "logout", | ||||||
|           )]); |           )); | ||||||
|     } else { |  | ||||||
|       popupMenuItems.addAll([ |  | ||||||
|         PopupMenuItem<String>( |  | ||||||
|           child: new Text("Connect"), |  | ||||||
|           value: "reload", |  | ||||||
|         )]); |  | ||||||
|     } |     } | ||||||
|     return NestedScrollView( |     return NestedScrollView( | ||||||
|       headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { |       headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { | ||||||
| @@ -629,14 +628,10 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|                     items: popupMenuItems |                     items: popupMenuItems | ||||||
|                   ).then((String val) { |                   ).then((String val) { | ||||||
|                     if (val == "reload") { |                     if (val == "reload") { | ||||||
|                       _refreshData(); |                       _reLoad(); | ||||||
|                     } else if (val == "logout") { |                     } else if (val == "logout") { | ||||||
|                       widget.homeAssistant.disconnect().then((_) { |                       widget.homeAssistant.logout().then((_) { | ||||||
|                         widget.homeAssistant.logout().then((_) { |                         _reLoad(); | ||||||
|                           setState(() { |  | ||||||
|                             _refreshData(); |  | ||||||
|                           }); |  | ||||||
|                         }); |  | ||||||
|                       }); |                       }); | ||||||
|                     } |                     } | ||||||
|                   }); |                   }); | ||||||
| @@ -764,7 +759,8 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker | |||||||
|     _showErrorSubscription?.cancel(); |     _showErrorSubscription?.cancel(); | ||||||
|     _startAuthSubscription?.cancel(); |     _startAuthSubscription?.cancel(); | ||||||
|     _reloadUISubscription?.cancel(); |     _reloadUISubscription?.cancel(); | ||||||
|     widget.homeAssistant?.disconnect(); |     //TODO disconnect | ||||||
|  |     //widget.homeAssistant?.disconnect(); | ||||||
|     super.dispose(); |     super.dispose(); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -37,7 +37,7 @@ class Panel { | |||||||
|       ); |       ); | ||||||
|     } else { |     } else { | ||||||
|       HomeAssistantModel haModel = HomeAssistantModel.of(context); |       HomeAssistantModel haModel = HomeAssistantModel.of(context); | ||||||
|       String url = "${haModel.homeAssistant.httpWebHost}/$urlPath"; |       String url = "${haModel.homeAssistant.connection.httpWebHost}/$urlPath"; | ||||||
|       Logger.d("Launching custom tab with $url"); |       Logger.d("Launching custom tab with $url"); | ||||||
|       HAUtils.launchURLInCustomTab(context, url); |       HAUtils.launchURLInCustomTab(context, url); | ||||||
|     } |     } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user