diff --git a/.idea/.idea.HSPI_TeslaPowerwall/.idea/indexLayout.xml b/.idea/.idea.HSPI_TeslaPowerwall/.idea/indexLayout.xml index 27ba142..7b08163 100644 --- a/.idea/.idea.HSPI_TeslaPowerwall/.idea/indexLayout.xml +++ b/.idea/.idea.HSPI_TeslaPowerwall/.idea/indexLayout.xml @@ -1,6 +1,6 @@ - + diff --git a/.idea/.idea.HSPI_TeslaPowerwall/riderModule.iml b/.idea/.idea.HSPI_TeslaPowerwall/riderModule.iml deleted file mode 100644 index 1a4e0d9..0000000 --- a/.idea/.idea.HSPI_TeslaPowerwall/riderModule.iml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/HSPI_TeslaPowerwall/HSPI.cs b/HSPI_TeslaPowerwall/HSPI.cs index d641c48..116f06a 100644 --- a/HSPI_TeslaPowerwall/HSPI.cs +++ b/HSPI_TeslaPowerwall/HSPI.cs @@ -20,6 +20,8 @@ public class HSPI : HspiBase private PowerwallClient _client; private string _gatewayIp = ""; + private string _gatewayUsername = ""; + private string _gatewayPassword = ""; private GatewayDeviceRefSet _devRefSet; private Timer _pollTimer; private IPlugInAPI.enumInterfaceStatus _interfaceStatus = IPlugInAPI.enumInterfaceStatus.OK; @@ -59,6 +61,8 @@ private async void CheckGatewayConnection() { this._pollTimer?.Stop(); this._gatewayIp = hs.GetINISetting("GatewayNetwork", "ip", "", IniFilename); + this._gatewayUsername = hs.GetINISetting("GatewayCredentials", "username", "", IniFilename); + this._gatewayPassword = hs.GetINISetting("GatewayCredentials", "password", "", IniFilename); Program.WriteLog(LogType.Info, $"Attempting to connect to Gateway at IP \"{this._gatewayIp}\""); @@ -69,7 +73,7 @@ private async void CheckGatewayConnection() { return; } - this._client = new PowerwallClient(this._gatewayIp); + this._client = new PowerwallClient(this._gatewayIp, this._gatewayUsername, this._gatewayPassword); try { @@ -130,6 +134,23 @@ private string BuildSettingsPage(string user, int userRights, string queryString sb.Append(textBox.Build()); sb.Append(""); + sb.Append("Gateway Customer Email:"); + sb.Append(""); + textBox = new clsJQuery.jqTextBox("GatewayUsername", "text", this._gatewayUsername, pageName, 30, true); + sb.Append(textBox.Build()); + sb.Append(""); + + sb.Append("Gateway Customer Password:"); + sb.Append(""); + textBox = new clsJQuery.jqTextBox("GatewayPassword", "password", this._gatewayPassword.Length == 0 ? "" : "*****", pageName, 30, true); + sb.Append(textBox.Build()); + sb.Append(""); + + sb.Append(""); + sb.Append("

Prior to Gateway firmware version 20.49.0, authentication was not required to retrieve energy statistics. In later versions, authentication is required.

"); + sb.Append("

These credentials are not your Tesla.com or Tesla app credentials. These credentials are set in the Gateway's web administration panel, which can be accessed at https://your.gateway.ip on your local network.

"); + sb.Append(""); + sb.Append(""); clsJQuery.jqButton doneBtn = new clsJQuery.jqButton("DoneBtn", "Done", pageName, false); @@ -160,9 +181,26 @@ public override string PostBackProc(string page, string data, string user, int u NameValueCollection postData = HttpUtility.ParseQueryString(data); string gwIp = postData.Get("GatewayIP"); - hs.SaveINISetting("GatewayNetwork", "ip", gwIp, IniFilename); - this._gatewayIp = gwIp; - Program.WriteLog(LogType.Info, $"Updating Gateway IP to \"{gwIp}\""); + if (gwIp != null) { + hs.SaveINISetting("GatewayNetwork", "ip", gwIp, IniFilename); + this._gatewayIp = gwIp; + Program.WriteLog(LogType.Info, $"Updating Gateway IP to \"{gwIp}\""); + } + + string gwUsername = postData.Get("GatewayUsername"); + if (gwUsername != null) { + hs.SaveINISetting("GatewayCredentials", "username", gwUsername, IniFilename); + this._gatewayUsername = gwUsername; + Program.WriteLog(LogType.Info, $"Updating Gateway username to \"{gwUsername}\""); + } + + string gwPassword = postData.Get("GatewayPassword"); + if (gwPassword != null && gwPassword != "*****") { + hs.SaveINISetting("GatewayCredentials", "password", gwPassword, IniFilename); + this._gatewayPassword = gwPassword; + Program.WriteLog(LogType.Info, "Updating Gateway password"); + } + CheckGatewayConnection(); return ""; diff --git a/HSPI_TeslaPowerwall/PowerwallClient.cs b/HSPI_TeslaPowerwall/PowerwallClient.cs index 738201c..b919feb 100644 --- a/HSPI_TeslaPowerwall/PowerwallClient.cs +++ b/HSPI_TeslaPowerwall/PowerwallClient.cs @@ -1,5 +1,9 @@ using System; +using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Net.Http; +using System.Text; +using System.Threading; using System.Threading.Tasks; using System.Web.Script.Serialization; using Scheduler; @@ -8,19 +12,81 @@ namespace HSPI_TeslaPowerwall { public class PowerwallClient { + public DateTime LastLogin { get; private set; } + public bool LoggingIn { get; private set; } + + private const int RequestTimeoutMs = 10000; + private readonly string _ipAddress; private readonly HttpClient _httpClient; private readonly JavaScriptSerializer _jsonSerializer; + private readonly string _email; + private readonly string _password; - public PowerwallClient(string ipAddress) { + public PowerwallClient(string ipAddress, string email, string password) { this._ipAddress = ipAddress; HttpClientHandler handler = new HttpClientHandler(); this._httpClient = new HttpClient(handler); this._jsonSerializer = new JavaScriptSerializer(); + this._email = email; + this._password = password; // Powerwall Gateway uses a self-signed certificate, so let's accept it unconditionally - handler.ServerCertificateCustomValidationCallback = - HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; + handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; + handler.UseCookies = true; + ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12; + } + + public async Task Login() { + if (_email.Length == 0 || _password.Length == 0) { + throw new Exception("No credentials configured"); + } + + LoggingIn = true; + + CancellationTokenSource cancelSrc = new CancellationTokenSource(); + CancellationToken cancel = cancelSrc.Token; + + Task timeout = Task.Delay(RequestTimeoutMs, cancel); + + string loginUrl = $"https://{_ipAddress}/api/login/Basic"; + Program.WriteLog(LogType.Console, loginUrl); + HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, loginUrl); + + LoginRequest loginRequest = new LoginRequest { + email = _email, + password = _password, + username = "customer", + force_sm_off = false + }; + + string loginRequestString = _jsonSerializer.Serialize(loginRequest); + req.Content = new StringContent(loginRequestString, Encoding.UTF8, "application/json"); + Task responseTask = _httpClient.SendAsync(req, cancel); + + Task finished = await Task.WhenAny(timeout, responseTask); + cancelSrc.Cancel(); + if (finished == timeout) { + LoggingIn = false; + throw new Exception("Login request timed out"); + } + + HttpResponseMessage res = ((Task) finished).Result; + string responseText = await res.Content.ReadAsStringAsync(); + dynamic content = _jsonSerializer.DeserializeObject(responseText); + Program.WriteLog(LogType.Console, $"Login request complete with status {res.StatusCode}"); + + req.Dispose(); + res.Dispose(); + + if (content.ContainsKey("error") && content["error"] != null) { + LoggingIn = false; + throw new Exception($"Login failed ({content["error"]})"); + } + + Program.WriteLog(LogType.Info, "Successfully logged into Gateway API"); + LastLogin = DateTime.Now; + LoggingIn = false; } public async Task GetSiteInfo() { @@ -83,16 +149,39 @@ public async Task GetSystemChargePercentage() { } private async Task GetApiContent(string endpoint) { + if (LoggingIn) { + Program.WriteLog(LogType.Console, $"Suppressing {endpoint} because we are actively logging in."); + } + HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Get, $"https://{this._ipAddress}/api{endpoint}"); HttpResponseMessage res = await this._httpClient.SendAsync(req); string responseText = await res.Content.ReadAsStringAsync(); dynamic content = this._jsonSerializer.DeserializeObject(responseText); + Program.WriteLog(LogType.Console, $"Request complete with status {res.StatusCode}"); + + if (res.StatusCode == HttpStatusCode.Forbidden) { + // We need to log in + req.Dispose(); + res.Dispose(); + + Program.WriteLog(LogType.Console, $"Request to {endpoint} failed with status code Forbidden; attempting to login"); + await Login(); + return await GetApiContent(endpoint); + } req.Dispose(); res.Dispose(); return content; } } + + [SuppressMessage("ReSharper", "InconsistentNaming")] + internal struct LoginRequest { + public string email; + public bool force_sm_off; + public string password; + public string username; + } public struct SiteInfo {