using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.RegularExpressions; using Crestron.SimplSharp; using Crestron.SimplSharp.CrestronIO; using Crestron.SimplSharp.Reflection; using Crestron.SimplSharpPro.CrestronThread; using Crestron.SimplSharp.CrestronWebSocketClient; using Crestron.SimplSharpPro; using Crestron.SimplSharp.Net.Http; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using PepperDash.Core; using PepperDash.Essentials.Core; using PepperDash.Essentials.Core.Config; using PepperDash.Essentials.Room.Cotija; namespace PepperDash.Essentials { public class CotijaSystemController : Device { WebSocketClient WSClient; /// /// Prevents post operations from stomping on each other and getting lost /// CEvent PostLockEvent = new CEvent(true, true); CEvent RegisterLockEvent = new CEvent(true, true); public CotijaConfig Config { get; private set; } Dictionary ActionDictionary = new Dictionary(StringComparer.InvariantCultureIgnoreCase); Dictionary PushedActions = new Dictionary(); CTimer ServerHeartbeatCheckTimer; long ServerHeartbeatInterval = 20000; CTimer ServerReconnectTimer; long ServerReconnectInterval = 5000; string SystemUuid; List RoomBridges = new List(); long ButtonHeartbeatInterval = 1000; /// /// Used for tracking HTTP debugging /// bool HttpDebugEnabled; /// /// /// /// /// /// public CotijaSystemController(string key, string name, CotijaConfig config) : base(key, name) { Config = config; Debug.Console(0, this, "Mobile UI controller initializing for server:{0}", config.ServerUrl); CrestronConsole.AddNewConsoleCommand(AuthorizeSystem, "mobileauth", "Authorizes system to talk to cotija server", ConsoleAccessLevelEnum.AccessOperator); CrestronConsole.AddNewConsoleCommand(s => ShowInfo(), "mobileinfo", "Shows information for current mobile control session", ConsoleAccessLevelEnum.AccessOperator); CrestronConsole.AddNewConsoleCommand(s => { s = s.Trim(); if(!string.IsNullOrEmpty(s)) { HttpDebugEnabled = (s.Trim() != "0"); } CrestronConsole.ConsoleCommandResponse("HTTP Debug {0}", HttpDebugEnabled ? "Enabled" : "Disabled"); }, "mobilehttpdebug", "1 enables more verbose HTTP response debugging", ConsoleAccessLevelEnum.AccessOperator); CrestronConsole.AddNewConsoleCommand(TestHttpRequest, "mobilehttprequest", "Tests an HTTP get to URL given", ConsoleAccessLevelEnum.AccessOperator); CrestronConsole.AddNewConsoleCommand(PrintActionDictionaryPaths, "showactionpaths", "Prints the paths in teh Action Dictionary", ConsoleAccessLevelEnum.AccessOperator); CrestronEnvironment.ProgramStatusEventHandler += new ProgramStatusEventHandler(CrestronEnvironment_ProgramStatusEventHandler); } /// /// Sends message to server to indicate the system is shutting down /// /// void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType) { if (programEventType == eProgramStatusEventType.Stopping && WSClient.Connected) { SendMessageToServer(JObject.FromObject( new { type = "/system/close" })); } } public void PrintActionDictionaryPaths(object o) { Debug.Console(0, this, "ActionDictionary Contents:"); foreach (var item in ActionDictionary) { Debug.Console(0, this, "{0}", item.Key); } } /// /// Adds an action to the dictionary /// /// The path of the API command /// The action to be triggered by the commmand public void AddAction(string key, object action) { if (!ActionDictionary.ContainsKey(key)) { ActionDictionary.Add(key, action); } else { Debug.Console(1, this, "Cannot add action with key '{0}' because key already exists in ActionDictionary.", key); } } /// /// Removes an action from the dictionary /// /// public void RemoveAction(string key) { if (ActionDictionary.ContainsKey(key)) ActionDictionary.Remove(key); } /// /// /// /// public void AddBridge(CotijaBridgeBase bridge) { RoomBridges.Add(bridge); var b = bridge as IDelayedConfiguration; if (b != null) { Debug.Console(0, this, "Adding room bridge with delayed configuration"); b.ConfigurationIsReady += new EventHandler(bridge_ConfigurationIsReady); } else { Debug.Console(0, this, "Adding room bridge and sending configuration"); RegisterSystemToServer(); } } /// /// /// /// /// void bridge_ConfigurationIsReady(object sender, EventArgs e) { Debug.Console(1, this, "Bridge ready. Registering"); // send the configuration object to the server RegisterSystemToServer(); } /// /// /// /// void ReconnectToServerTimerCallback(object o) { RegisterSystemToServer(); } /// /// Verifies system connection with servers /// /// void AuthorizeSystem(string code) { if (string.IsNullOrEmpty(SystemUuid)) { CrestronConsole.ConsoleCommandResponse("System does not have a UUID. Please ensure proper portal-format configuration is loaded and restart."); return; } if (string.IsNullOrEmpty(code)) { CrestronConsole.ConsoleCommandResponse("Please enter a user code to authorize a system"); return; } var req = new HttpClientRequest(); string url = string.Format("http://{0}/api/system/grantcode/{1}/{2}", Config.ServerUrl, code, SystemUuid); Debug.Console(0, this, "Authorizing to: {0}", url); if (string.IsNullOrEmpty(Config.ServerUrl)) { CrestronConsole.ConsoleCommandResponse("Config URL address is not set. Check portal configuration"); return; } try { req.Url.Parse(url); new HttpClient().DispatchAsync(req, (r, e) => { CheckHttpDebug(r, e); if (e == HTTP_CALLBACK_ERROR.COMPLETED) { if (r.Code == 200) { Debug.Console(0, "System authorized, sending config."); RegisterSystemToServer(); } else if (r.Code == 404) { if (r.ContentString.Contains("codeNotFound")) { Debug.Console(0, "Authorization failed, code not found for system UUID {0}", SystemUuid); } else if (r.ContentString.Contains("uuidNotFound")) { Debug.Console(0, "Authorization failed, uuid {0} not found. Check Essentials configuration is correct", SystemUuid); } } } else Debug.Console(0, this, "Error {0} in authorizing system", e); }); } catch (Exception e) { Debug.Console(0, this, "Error in authorizing: {0}", e); } } /// /// Dumps info in response to console command. /// void ShowInfo() { var url = Config != null ? Config.ServerUrl : "No config"; string name; string code; if (RoomBridges != null && RoomBridges.Count > 0) { name = RoomBridges[0].RoomName; code = RoomBridges[0].UserCode; } else { name = "No config"; code = "Not available"; } var conn = WSClient == null ? "No client" : (WSClient.Connected ? "Yes" : "No"); CrestronConsole.ConsoleCommandResponse(@"Mobile Control Information: Server address: {0} System Name: {1} System UUID: {2} System User code: {3} Connected?: {4}", url, name, SystemUuid, code, conn); } /// /// Registers the room with the server /// /// URL of the server, including the port number, if not 80. Format: "serverUrlOrIp:port" void RegisterSystemToServer() { var ready = RegisterLockEvent.Wait(20000); if (!ready) { Debug.Console(1, this, "RegisterSystemToServer failed to enter after 20 seconds. Ignoring"); return; } RegisterLockEvent.Reset(); try { var confObject = ConfigReader.ConfigObject; confObject.Info.RuntimeInfo.AppName = Assembly.GetExecutingAssembly().GetName().Name; var version = Assembly.GetExecutingAssembly().GetName().Version; confObject.Info.RuntimeInfo.AssemblyVersion = string.Format("{0}.{1}.{2}", version.Major, version.Minor, version.Build); string postBody = JsonConvert.SerializeObject(confObject); SystemUuid = confObject.SystemUuid; if (string.IsNullOrEmpty(postBody)) { Debug.Console(1, this, "ERROR: Config body is empty. Cannot register with server."); } else { var regClient = new HttpClient(); regClient.Verbose = true; regClient.KeepAlive = true; string url = string.Format("http://{0}/api/system/join/{1}", Config.ServerUrl, SystemUuid); Debug.Console(1, this, "Joining server at {0}", url); HttpClientRequest request = new HttpClientRequest(); request.Url.Parse(url); request.RequestType = RequestType.Post; request.Header.SetHeaderValue("Content-Type", "application/json"); request.ContentString = postBody; var err = regClient.DispatchAsync(request, RegistrationConnectionCallback); } } catch (Exception e) { Debug.Console(0, this, "ERROR: Initilizing Room: {0}", e); RegisterLockEvent.Set(); StartReconnectTimer(); } } /// /// Sends a message to the server from a room /// /// room from which the message originates /// object to be serialized and sent in post body public void SendMessageToServer(JObject o) { if (WSClient != null && WSClient.Connected) { string message = JsonConvert.SerializeObject(o, Formatting.None, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); Debug.Console(1, this, "Message TX: {0}", message); var messageBytes = System.Text.Encoding.UTF8.GetBytes(message); WSClient.Send(messageBytes, (uint)messageBytes.Length, WebSocketClient.WEBSOCKET_PACKET_TYPES.LWS_WS_OPCODE_07__TEXT_FRAME); //WSClient.SendAsync(messageBytes, (uint)messageBytes.Length, WebSocketClient.WEBSOCKET_PACKET_TYPES.LWS_WS_OPCODE_07__TEXT_FRAME); } } /// /// Disconnects the SSE Client and stops the heartbeat timer /// /// void DisconnectStreamClient(string command) { //if(SseClient != null) // SseClient.Disconnect(); if (WSClient != null && WSClient.Connected) WSClient.Disconnect(); if (ServerHeartbeatCheckTimer != null) { ServerHeartbeatCheckTimer.Stop(); ServerHeartbeatCheckTimer = null; } } /// /// The callback that fires when we get a response from our registration attempt /// /// /// void RegistrationConnectionCallback(HttpClientResponse resp, HTTP_CALLBACK_ERROR err) { CheckHttpDebug(resp, err); Debug.Console(1, this, "RegistrationConnectionCallback: {0}", err); try { if (resp != null && resp.Code == 200) { if(ServerReconnectTimer != null) { ServerReconnectTimer.Stop(); ServerReconnectTimer = null; } // Success here! ConnectStreamClient(); } else { if (resp != null) Debug.Console(1, this, "Response from server: {0}\n{1}", resp.Code, err); else { Debug.Console(1, this, "Null response received from server."); } StartReconnectTimer(); } } catch (Exception e) { Debug.Console(1, this, "Error Initializing Stream Client: {0}", e); StartReconnectTimer(); } RegisterLockEvent.Set(); } /// /// Executes when we don't get a heartbeat message in time. Triggers reconnect. /// /// For CTimer callback. Not used void HeartbeatExpiredTimerCallback(object o) { Debug.Console(1, this, "Heartbeat Timer Expired."); if (ServerHeartbeatCheckTimer != null) { ServerHeartbeatCheckTimer.Stop(); ServerHeartbeatCheckTimer = null; } StartReconnectTimer(); } /// /// /// /// /// void StartReconnectTimer() { // Start the reconnect timer if (ServerReconnectTimer == null) { ServerReconnectTimer = new CTimer(ReconnectToServerTimerCallback, null, ServerReconnectInterval, ServerReconnectInterval); Debug.Console(1, this, "Reconnect Timer Started."); } ServerReconnectTimer.Reset(ServerReconnectInterval, ServerReconnectInterval); } /// /// /// /// /// void ResetOrStartHearbeatTimer() { if (ServerHeartbeatCheckTimer == null) { ServerHeartbeatCheckTimer = new CTimer(HeartbeatExpiredTimerCallback, null, ServerHeartbeatInterval, ServerHeartbeatInterval); Debug.Console(1, this, "Heartbeat Timer Started."); } ServerHeartbeatCheckTimer.Reset(ServerHeartbeatInterval, ServerHeartbeatInterval); } /// /// Connects the SSE Client /// /// void ConnectStreamClient() { Debug.Console(0, this, "Initializing Stream client to server."); if (WSClient == null) { WSClient = new WebSocketClient(); } WSClient.URL = string.Format("wss://{0}/system/join/{1}", Config.ServerUrl, this.SystemUuid); WSClient.Connect(); Debug.Console(0, this, "Websocket connected"); WSClient.ReceiveCallBack = WebsocketReceiveCallback; //WSClient.SendCallBack = WebsocketSendCallback; WSClient.ReceiveAsync(); } /// /// Resets reconnect timer and updates usercode /// /// void HandleHeartBeat(JToken content) { SendMessageToServer(JObject.FromObject(new { type = "/system/heartbeatAck" })); var code = content["userCode"]; if(code != null) { foreach (var b in RoomBridges) { b.SetUserCode(code.Value()); } } ResetOrStartHearbeatTimer(); } /// /// Outputs debug info when enabled /// /// /// /// void CheckHttpDebug(HttpClientResponse r, HTTP_CALLBACK_ERROR e) { if (HttpDebugEnabled) { Debug.Console(0, this, "------ Begin HTTP Debug ---------------------------------------"); Debug.Console(0, this, "HTTP Response URL: {0}", r.ResponseUrl.ToString()); Debug.Console(0, this, "HTTP Response 'error' {0}", e); Debug.Console(0, this, "HTTP Response code: {0}", r.Code); Debug.Console(0, this, "HTTP Response content: \r{0}", r.ContentString); Debug.Console(0, this, "------ End HTTP Debug -----------------------------------------"); } } /// /// /// /// /// /// /// int WebsocketReceiveCallback(byte[] data, uint length, WebSocketClient.WEBSOCKET_PACKET_TYPES opcode, WebSocketClient.WEBSOCKET_RESULT_CODES err) { var rx = System.Text.Encoding.UTF8.GetString(data, 0, (int)length); if(rx.Length > 0) ParseStreamRx(rx); WSClient.ReceiveAsync(); return 1; } /// /// Callback to catch possible errors in sending via the websocket /// /// /// int WebsocketSendCallback(Crestron.SimplSharp.CrestronWebSocketClient.WebSocketClient.WEBSOCKET_RESULT_CODES result) { Debug.Console(1, this, "SendCallback result: {0}", result); return 1; } /// /// /// /// /// void ParseStreamRx(string message) { if(string.IsNullOrEmpty(message)) return; Debug.Console(1, this, "Message RX: '{0}'", message); try { var messageObj = JObject.Parse(message); var type = messageObj["type"].Value(); if (type == "hello") { ResetOrStartHearbeatTimer(); } else if (type == "/system/heartbeat") { HandleHeartBeat(messageObj["content"]); } else if (type == "close") { WSClient.Disconnect(); ServerHeartbeatCheckTimer.Stop(); // Start the reconnect timer StartReconnectTimer(); } else { // Check path against Action dictionary if (ActionDictionary.ContainsKey(type)) { var action = ActionDictionary[type]; if (action is Action) { (action as Action)(); } else if (action is PressAndHoldAction) { var stateString = messageObj["content"]["state"].Value(); // Look for a button press event if (!string.IsNullOrEmpty(stateString)) { switch (stateString) { case "true": { if (!PushedActions.ContainsKey(type)) { PushedActions.Add(type, new CTimer(o => { (action as PressAndHoldAction)(false); PushedActions.Remove(type); }, null, ButtonHeartbeatInterval, ButtonHeartbeatInterval)); } // Maybe add an else to reset the timer break; } case "held": { if (!PushedActions.ContainsKey(type)) { PushedActions[type].Reset(ButtonHeartbeatInterval, ButtonHeartbeatInterval); } return; } case "false": { if (PushedActions.ContainsKey(type)) { PushedActions[type].Stop(); PushedActions.Remove(type); } break; } } (action as PressAndHoldAction)(stateString == "true"); } } else if (action is Action) { var stateString = messageObj["content"]["state"].Value(); if (!string.IsNullOrEmpty(stateString)) { (action as Action)(stateString == "true"); } } else if (action is Action) { (action as Action)(messageObj["content"]["value"].Value()); } else if (action is Action) { (action as Action)(messageObj["content"]["value"].Value()); } else if (action is Action) { (action as Action)(messageObj["content"] .ToObject()); } } else { Debug.Console(1, this, "-- Warning: Incoming message has no registered handler"); } } } catch (Exception err) { //Debug.Console(1, "SseMessageLengthBeforeFailureCount: {0}", SseMessageLengthBeforeFailureCount); //SseMessageLengthBeforeFailureCount = 0; Debug.Console(1, this, "Unable to parse message: {0}", err); } } void TestHttpRequest(string s) { { s = s.Trim(); if (string.IsNullOrEmpty(s)) { PrintTestHttpRequestUsage(); return; } var tokens = s.Split(' '); if (tokens.Length < 2) { CrestronConsole.ConsoleCommandResponse("Too few paramaters\r"); PrintTestHttpRequestUsage(); return; } try { var url = tokens[1]; if (tokens[0].ToLower() == "get") { var resp = new HttpClient().Get(url); CrestronConsole.ConsoleCommandResponse("RESPONSE:\r{0}\r\r", resp); } else if (tokens[0].ToLower() == "post") { var resp = new HttpClient().Post(url, new byte[] { }); CrestronConsole.ConsoleCommandResponse("RESPONSE:\r{0}\r\r", resp); } else { CrestronConsole.ConsoleCommandResponse("Only get or post supported\r"); PrintTestHttpRequestUsage(); } } catch (HttpException e) { CrestronConsole.ConsoleCommandResponse("Exception in request:\r"); CrestronConsole.ConsoleCommandResponse("Response URL: {0}\r", e.Response.ResponseUrl); CrestronConsole.ConsoleCommandResponse("Response Error Code: {0}\r", e.Response.Code); CrestronConsole.ConsoleCommandResponse("Response body: {0}\r", e.Response.ContentString); } } } void PrintTestHttpRequestUsage() { CrestronConsole.ConsoleCommandResponse("Usage: mobilehttprequest:N get/post url\r"); } } }