Project 027 - Solis Cloud API - VB.NET sample code

DISCLAIMER: This design is experimental, so if you decide to build one yourself then you are on your own, I can't be held responsible for any problems/issues/damage/injury that may occur if you decide to follow this build and make one yourself.

I have a Solis Inverter (non-hybrid) connected to 10.65kWh Lithium-Iron-Phosphate batteries in the house.
I have a VB app running on a web server in the house that I use to monitor and control a whol;e host of energy related hardware including my workshop aircon, and wanted to extend it's functionality to monitor my new Solis Inverter.
I also pick up weather forecast data for the next day via OpenWeatherMap.org API and determine what charging should happen overnight (Octopus Flux Tarif, cheap rate 2-5am), and update the Solis inverter charging timings.

I don't offer much of an explanation of how the code works, I just quote it below to give anyone else some ideas since the Solis API is quite a feat to understand when you also consider the API is a 122 page document.

Important: You cannot access the API data unless you have activated it via Solis. You will need to sign a document. You also need an OpenWeatherMap.org API account.

Here's snippets of my VB.net code, note the real Key and Secret Key are omitted for obvious reasons.

Imports System.Security.Cryptography
Imports System.Text
Imports Newtonsoft.Json.Linq
Imports Newtonsoft.Json
Imports System.Net.Http
Imports System.Globalization
Imports System.Net.Http.Headers
Imports System.Text.RegularExpressions
Imports System
Imports System.Threading
Imports System.Runtime.InteropServices

Partial Class FormMain

Private Sub ButtonChargeSet_Click(sender As Object, e As EventArgs) Handles ButtonChargeSet.Click

If CheckBox7.Checked And CheckBox8.Checked Then
Call OpenWeatherIrridiance() ' Manually get irridiance figures and then send updated settings back to Solis Inverter (battery charge discharge timings)
End If

End Sub


Private Sub SendToSolis()

Try

' Check user entered data, abort sub with pop-up if wrong
Dim Charge1SetOn As String = TextBoxChargeSetOn.Text
Dim Charge1SetOff As String = TextBoxChargeSetOff.Text
' Define the regex pattern for XX:XX format
Dim timePattern As String = "^\d{2}:\d{2}$"
' Check if the strings match the pattern
If Not Regex.IsMatch(Charge1SetOn, timePattern) OrElse Not Regex.IsMatch(Charge1SetOff, timePattern) Then
' If either string does not match the pattern, exit the sub
MessageBox.Show("Please enter the time in the format XX:XX")
Exit Sub
End If

Dim key As String = "#####################" ' Private key from Solis
Dim keySecret As String = "############################" ' Secret key from Solis

' Create a comma-separated value for cid 103 using the inputs and default values
Dim value As String = $"70,50,{Charge1SetOn},{Charge1SetOff},00:00,00:00,70,50,00:00,00:00,00:00,00:00,70,50,00:00,00:00,00:00,00:00"

' Create the map for the API request
Dim map As New Dictionary(Of String, Object) From {
{"inverterSn", "##############"}, ' Replace with your actual inverter serial number
{"cid", "103"},
{"value", value} ' Set the parameters as a single comma-separated string
}

' Serialize the map to JSON for the API request
Dim body As String = JsonConvert.SerializeObject(map)
Dim ContentMd5 As String = GetDigest(body)
Dim [Date] As String = GetGMTTime()
Dim path As String = "/v2/api/control"
Dim param As String = "POST" & vbLf & ContentMd5 & vbLf & "application/json" & vbLf & [Date] & vbLf & path
Dim sign As String = HmacSHA1Encrypt(param, keySecret)
Dim url As String = "https://www.soliscloud.com:13333" & path ' URL for the control endpoint
Dim client As New HttpClient()
Dim requestBody As HttpContent = New StringContent(body, Encoding.UTF8, "application/json")

' Set Content-Type and Content-MD5
requestBody.Headers.ContentType = New MediaTypeHeaderValue("application/json") With {
.CharSet = "UTF-8"
}
requestBody.Headers.ContentMD5 = Convert.FromBase64String(ContentMd5)

Dim request As New HttpRequestMessage(HttpMethod.Post, url)
request.Headers.Add("Authorization", "API " & key & ":" & sign)
request.Headers.Add("Date", [Date])
request.Content = requestBody

' Send the request
Dim response As HttpResponseMessage = client.SendAsync(request).Result ' Non-async for timing purposes
Dim result As String = response.Content.ReadAsStringAsync().Result

RunningSeq.Text = "Irridiance received, Battery settings updated"

Catch ex As Exception
Console.WriteLine(ex.ToString())
End Try

End Sub


Private Sub CheckBox7_CheckedChanged(sender As Object, e As EventArgs) Handles CheckBox7.CheckedChanged

If CheckBox7.Checked = True Then
ButtonChargeSet.Enabled = True
Else
ButtonChargeSet.Enabled = False
End If

End Sub


Private Sub OpenWeatherIrridiance()
On Error GoTo ErrorHandler

' get solar irridiance from openweathermap.org
' API = ##################################
' appid = API key

If CheckBox8.Checked = True Then

Dim TomorrowDate As String = DateTime.Now.AddDays(1).ToString("yyyy-MM-dd") ' tomorrow date, so run this sub before midnight
Dim IrridianceAPIkey As String = "################################"
' openweathermap ping retry
Dim deviceAddress As String = "openweathermap.org"
Dim maxRetries As Integer = 3
Dim retryDelaySeconds As Double = 0.2

' Call Function
If TryPingDevice(deviceAddress, maxRetries, retryDelaySeconds) Then
' Ping was successful, continue with your specific actions

Dim IrridianceData As String = ""

IrridianceData = New System.Net.WebClient().DownloadString("https://api.openweathermap.org/energy/1.0/solar/data?lat=56.9734&lon=-2.2252&date=" & TomorrowDate & "&" & "appid=" & IrridianceAPIkey)
IsOkIrridiance = True

' Turn on the LED
LEDirridiance.State = OnOffLed.LedState.OnSmallYellow
' Start the timer so the LED will light for 1sec
IndicatorTimer3.Interval = 2000 ' 1 second
IndicatorTimer3.Start()

' Sample return
' {"lat":56.9734,"lon":2.2252,"date":"2024-09-12","tz":"+00:00","sunrise":"2024-09-12T05:16:09","sunset":"2024-09-12T18:16:56","irradiance":{"daily":[{"clear_sky":{"ghi":4454.41,"dni":8160.83,"dhi":974.92},"cloudy_sky":{"ghi":1176.86,"dni":0.0,"dhi":1176.86}}],"hourly":[{"hour":0,"clear_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0},"cloudy_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0}},{"hour":1,"clear_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0},"cloudy_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0}},{"hour":2,"clear_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0},"cloudy_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0}},{"hour":3,"clear_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0},"cloudy_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0}},{"hour":4,"clear_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0},"cloudy_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0}},{"hour":5,"clear_sky":{"ghi":13.3,"dni":92.91,"dhi":13.27},"cloudy_sky":{"ghi":3.83,"dni":0.0,"dhi":3.83}},{"hour":6,"clear_sky":{"ghi":112.37,"dni":436.99,"dhi":48.97},"cloudy_sky":{"ghi":31.06,"dni":0.0,"dhi":31.06}},{"hour":7,"clear_sky":{"ghi":244.8,"dni":617.18,"dhi":70.05},"cloudy_sky":{"ghi":61.2,"dni":0.0,"dhi":61.2}},{"hour":8,"clear_sky":{"ghi":372.29,"dni":715.88,"dhi":83.97},"cloudy_sky":{"ghi":98.83,"dni":0.0,"dhi":98.83}},{"hour":9,"clear_sky":{"ghi":478.25,"dni":774.19,"dhi":93.27},"cloudy_sky":{"ghi":136.31,"dni":0.0,"dhi":136.31}},{"hour":10,"clear_sky":{"ghi":551.52,"dni":806.91,"dhi":98.91},"cloudy_sky":{"ghi":153.17,"dni":0.0,"dhi":153.17}},{"hour":11,"clear_sky":{"ghi":584.97,"dni":820.27,"dhi":101.33},"cloudy_sky":{"ghi":160.0,"dni":0.0,"dhi":160.0}},{"hour":12,"clear_sky":{"ghi":575.45,"dni":816.47,"dhi":100.67},"cloudy_sky":{"ghi":151.91,"dni":0.0,"dhi":151.91}},{"hour":13,"clear_sky":{"ghi":523.81,"dni":794.88,"dhi":96.9},"cloudy_sky":{"ghi":130.95,"dni":0.0,"dhi":130.95}},{"hour":14,"clear_sky":{"ghi":434.94,"dni":751.78,"dhi":89.75},"cloudy_sky":{"ghi":108.77,"dni":0.0,"dhi":108.77}},{"hour":15,"clear_sky":{"ghi":317.69,"dni":678.03,"dhi":78.58},"cloudy_sky":{"ghi":79.5,"dni":0.0,"dhi":79.5}},{"hour":16,"clear_sky":{"ghi":185.27,"dni":550.99,"dhi":61.93},"cloudy_sky":{"ghi":46.36,"dni":0.0,"dhi":46.36}},{"hour":17,"clear_sky":{"ghi":59.13,"dni":298.83,"dhi":35.37},"cloudy_sky":{"ghi":14.8,"dni":0.0,"dhi":14.8}},{"hour":18,"clear_sky":{"ghi":0.62,"dni":5.51,"dhi":1.96},"cloudy_sky":{"ghi":0.15,"dni":0.0,"dhi":0.15}},{"hour":19,"clear_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0},"cloudy_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0}},{"hour":20,"clear_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0},"cloudy_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0}},{"hour":21,"clear_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0},"cloudy_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0}},{"hour":22,"clear_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0},"cloudy_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0}},{"hour":23,"clear_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0},"cloudy_sky":{"ghi":0.0,"dni":0.0,"dhi":0.0}}]}}

' Pull total irridiance value from returned string
If (IrridianceData.Length() > 2000) Then ' usually 2885 approx.

' Your JSON string
Dim json As String = IrridianceData

' Parse the JSON string
Dim data As JObject = JObject.Parse(json)

' Get the hourly irradiance array
Dim hourlyIrradiance As JArray = data("irradiance")("hourly")

' Initialize variables to store the total irradiance for both clear and cloudy skies
Dim totalClearSkyIrradiance As Double = 0
Dim totalCloudySkyIrradiance As Double = 0

' Loop through each hourly object and sum the "ghi" (global horizontal irradiance) for both clear and cloudy skies
For Each hourData As JObject In hourlyIrradiance
Dim clearSkyGhi As Double = hourData("clear_sky")("ghi")
Dim cloudySkyGhi As Double = hourData("cloudy_sky")("ghi")

totalClearSkyIrradiance += clearSkyGhi
totalCloudySkyIrradiance += cloudySkyGhi
Next

' Output the total summed irradiance for both clear and cloudy skies
Console.WriteLine("Total summed irradiance for the day (clear sky): " & totalClearSkyIrradiance.ToString())
Console.WriteLine("Total summed irradiance for the day (cloudy sky): " & totalCloudySkyIrradiance.ToString())

' You can use either of the summed values or calculate an average for decision making
Dim overallIrradiance As Double = (totalClearSkyIrradiance + totalCloudySkyIrradiance) / 2

Irridiance.Text = overallIrradiance

' Output the overall irradiance
Console.WriteLine("Overall summed irradiance: " & overallIrradiance.ToString())

' now determine if batteries should charge. Figures below from ChatGPT
' Overcast days: 200-500 W/m²
' Cloudy days: 500-1000 W/m²
' Cloudy/sunny days: 1000-2000 W/m²
' Sunny winter days: 2000-3000 W/m²
' Sunny summer days: 5000-7000 W/m²

If overallIrradiance <= 500 Then
TextBoxChargeSetOn.Text = "02:00"
TextBoxChargeSetOff.Text = "05:00"
End If
If overallIrradiance > 500 And overallIrradiance <= 1000 Then
TextBoxChargeSetOn.Text = "02:00"
TextBoxChargeSetOff.Text = "04:30"
End If
If overallIrradiance > 1000 And overallIrradiance <= 2000 Then
TextBoxChargeSetOn.Text = "02:00"
TextBoxChargeSetOff.Text = "04:00"
End If
If overallIrradiance > 2000 And overallIrradiance <= 3000 Then
TextBoxChargeSetOn.Text = "02:00"
TextBoxChargeSetOff.Text = "03:30"
End If
If overallIrradiance > 3000 And overallIrradiance <= 5000 Then
TextBoxChargeSetOn.Text = "02:00"
TextBoxChargeSetOff.Text = "02:30"
End If
If overallIrradiance > 5000 And overallIrradiance <= 7000 Then
TextBoxChargeSetOn.Text = "00:00"
TextBoxChargeSetOff.Text = "00:00"
End If

My.Settings.data40 = TextBoxChargeSetOn.Text
My.Settings.data41 = TextBoxChargeSetOff.Text
My.Settings.Save()

Call SendToSolis() ' Got the irridiance value so can now send the required settings to Solis

' Turn off the LED
LEDirridiance.State = OnOffLed.LedState.OffSmallBlack

End If

Else
Dim currentDateAndTime As DateTime = DateTime.Now
Dim formattedDateTime As String = currentDateAndTime.ToString("dd-MM-yyyy HH:mm", CultureInfo.InvariantCulture)
ErrorCode.Text = formattedDateTime & " " & "openweathermap.org ping fail" 'ToErrorString(Err) ' display error status
IsOkIrridiance = False
LEDirridiance.State = OnOffLed.LedState.OffSmall ' fail RED led
Exit Sub
End If

End If

ErrorHandler:
End Sub

Private Async Sub Battery()

Dim batteryPower As Double = 0

If CheckBox4.Checked Then ' Solar read must have been done successfully before OB418 read can take place

' Async/Await: The HttpClient operations are now asynchronous, preventing the UI thread from being blocked.

Try
Dim key As String = "######################" ' Private key from Solis
Dim keySecret As String = "################################" ' Secret key from Solis

Dim map As New Dictionary(Of String, Object) From {
{"pageNo", 1},
{"pageSize", 10}
}
Dim body As String = JsonConvert.SerializeObject(map)
Dim ContentMd5 As String = GetDigest(body)
Dim [Date] As String = GetGMTTime()
Dim path As String = "/v1/api/inverterList"
Dim param As String = "POST" & vbLf & ContentMd5 & vbLf & "application/json" & vbLf & [Date] & vbLf & path
Dim sign As String = HmacSHA1Encrypt(param, keySecret)
Dim url As String = "https://www.soliscloud.com:13333" & path ' Url from Solis
Dim client As New HttpClient()
Dim requestBody As HttpContent = New StringContent(body, Encoding.UTF8, "application/json")

' Set Content-Type and Content-MD5 directly when creating StringContent
requestBody.Headers.ContentType = New MediaTypeHeaderValue("application/json") With {
.CharSet = "UTF-8"
}
requestBody.Headers.ContentMD5 = Convert.FromBase64String(ContentMd5)

Dim request As New HttpRequestMessage(HttpMethod.Post, url)
request.Headers.Add("Authorization", "API " & key & ":" & sign)
request.Headers.Add("Date", [Date])
request.Content = requestBody

Dim response As HttpResponseMessage = client.SendAsync(request).Result ' non-asynchronous - Sub will wait for response, stopwatch records properly
Dim result As String = response.Content.ReadAsStringAsync().Result

' Now pull data from JSON string
Dim jsonObject As JObject = JObject.Parse(result) ' Parse the JSON string

' Access the "records" array within the "page" property
Dim recordsArray As JArray = jsonObject.SelectToken("data.page.records")

' Check if the "records" array is not null and contains at least one item
If recordsArray IsNot Nothing AndAlso recordsArray.Any() Then
' Access the first item in the "records" array
Dim firstRecord As JObject = recordsArray.First

' Access the "batteryCapacitySoc" property within the first record
Dim batteryCapacitySoc As Double = 0
If firstRecord.TryGetValue("batteryCapacitySoc", batteryCapacitySoc) Then
IsOkBattery = True
LEDbattery.State = OnOffLed.LedState.OnSmall

BatteryCapacity.Text = Math.Round(batteryCapacitySoc, 0).ToString() ' 0dp

Else
IsOkBattery = False
LEDbattery.State = OnOffLed.LedState.OffSmall
End If

' Access the "batterypower" property within the first record, also charging status
If DataGlitch = False Then
If firstRecord.TryGetValue("batteryPower", batteryPower) Then

IsOkBattery = True
LEDbattery.State = OnOffLed.LedState.OnSmall

If batteryPower > 0 Then
BattStatus.Text = "Charging"
ElseIf batteryPower < 0 Then
BattStatus.Text = "Discharging"
Else
BattStatus.Text = "Static"
End If

batteryPower *= 1000 ' kW to W
BattChg.Text = Math.Round(batteryPower, 0).ToString() ' 0dp
Else
IsOkBattery = False
LEDbattery.State = OnOffLed.LedState.OffSmall
End If
End If

' House Consumption
If DataGlitch = False Then
Dim solarWValue As Double = Val(SolarW.Text)
Dim consumptionValue As Double = solarWValue + OB418DataMeterPwr - batteryPower

Consumption.Text = FormatNumber(consumptionValue, 1).Replace(",", "")
End If

DataGlitch = False ' reset glitch flag
RunningSeq.Text = "Received Solis battery data"

Else
' Handle the case where "records" array is empty or null
IsOkBattery = False
LEDbattery.State = OnOffLed.LedState.OffSmall
End If

Catch ex As Exception
Console.WriteLine(ex.ToString())
End Try

End If

End Sub

Function HmacSHA1Encrypt(encryptText As String, KeySecret As String) As String
Dim data As Byte() = Encoding.UTF8.GetBytes(KeySecret)
Dim secretKey As New HMACSHA1(data)
Dim text As Byte() = Encoding.UTF8.GetBytes(encryptText)
Dim result As Byte() = secretKey.ComputeHash(text)
Return Convert.ToBase64String(result)
End Function

Function GetGMTTime() As String
Return DateTime.UtcNow.ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.InvariantCulture)
End Function

Function GetDigest(test As String) As String
Dim result As String = ""
Try
Using md5 As System.Security.Cryptography.MD5 = System.Security.Cryptography.MD5.Create()
Dim data As Byte() = md5.ComputeHash(Encoding.UTF8.GetBytes(test))
result = Convert.ToBase64String(data)
End Using
Catch ex As Exception
Console.WriteLine(ex.ToString())
End Try
Return result
End Function

End Class