This project is a little web app to show the basics of using the SolarNetwork API in a TypeScript project to render an accumulating meter reading datum stream in a chart with the billboard.js project to generate a chart out of a SolarNetwork datum stream.
You can see the example in action here:
https://go.solarnetwork.net/dev/example/typescript-chart-billboard/
There are a few key aspects of this example worth pointing out.
The solarnetwork-api-core package is included in the project, which provides many helpful utilities in both TypeScript and JavaScript for working with the SolarNetwork API.
{
"dependencies": {
"solarnetwork-api-core": "^2.0.1"
}
}
The example demonstrates using SolarNetwork token authentication with the browser Fetch API.
First the demo imports the AuthorizationV2Builder class and creates a reusable instance in an auth
variable:
import { AuthorizationV2Builder } from "solarnetwork-api-core/lib/net";
const auth = new AuthorizationV2Builder();
A change
form event handler listens for changes to the form's token and secret fields, and saves the credentials for future API calls:
// save credentials
auth.tokenId = settingsForm.snToken.value;
auth.saveSigningKey(settingsForm.snTokenSecret.value);
When it comes time to make a SolarNetwork API request, the app generates a Headers
object for the API URL that includes the necessary Authorization
, X-SN-Date
, and Accept
header values and initiates the fetch()
call:
function authorizeUrl(url: string): Headers {
const authHeader = auth.reset().snDate(true).url(url).buildWithSavedKey();
return new Headers({
Authorization: authHeader,
"X-SN-Date": auth.requestDateHeaderValue!,
Accept: "application/json",
});
}
const headers = authorizeUrl(findSourcesUrl);
const res = await fetch(findSourcesUrl, {
method: "GET",
headers: headers,
});
The SolarQueryApi
class is also imported, which provides methods to help generate SolarNetwork API URLs:
import { SolarQueryApi } from "solarnetwork-api-core/lib/net";
const urlHelper = new SolarQueryApi();
For example, to discover the available sources to populate the Source ID menu, the application creates a DatumFilter
object and populates the Node ID, Start Date, and End Date values from the form, and then uses the findSourcesUrl(filter)
method to generate the API URL:
import { DatumFilter } from "solarnetwork-api-core/lib/domain";
// create a filter object with the form's node ID, start date, and end date values
const filter = new DatumFilter();
filter.nodeId = Number(nodeId);
if (startDate) {
startDate.setHours(0, 0, 0, 0);
filter.localStartDate = startDate;
}
if (endDate) {
endDate.setHours(0, 0, 0, 0);
filter.localEndDate = endDate;
}
// use the findSourcesUrl() method to generate the API URL
const findSourcesUrl = urlHelper.findSourcesUrl(filter);
The the previous section actually showed how the app takes the Node ID, Start Date, and End Date values from the form and uses the SolarQueryApi
helper's findSourcesUrl(filter)
method to query SolarNetwork for the source IDs available matching that criteria. The API used here is the /nodes/sources method, that returns a list of node/source objects like this:
{
"success": true,
"data": [
{ "nodeId": 1, "sourceId": "Main" },
{ "nodeId": 1, "sourceId": "Main1" }
]
}
Thus the results are processed and all the available sourceId
values are populated in the Source ID form menu:
// make API request using Fetch API
const res = await fetch(findSourcesUrl, {
method: "GET",
headers: headers,
});
// wait for response
const json = await res.json();
if (json && Array.isArray(json.data)) {
// clear out and re-populate the source IDs menu
while (settingsForm.snSourceId.length) {
settingsForm.snSourceId.remove(0);
}
if (json.data.length) {
settingsForm.snSourceId.add(new Option("Choose...", ""));
// for each response object, add a menu option for that source ID
for (const src of json.data) {
const opt = new Option(src.sourceId, src.sourceId);
settingsForm.snSourceId.add(opt);
}
}
}
In order to populate the Datum Property form menu with a list of the available meter-style properties of the selected Source ID the app queries the /datum/stream/meta/node method using the Node ID and Source ID values in the form. This method returns a list of datum stream metadata objects and looks like this:
{
"success": true,
"data": [
{
"streamId": "9458020e-789b-49d5-8a29-d9b53fde622f",
"zone": "Pacific/Auckland",
"kind": "n",
"objectId": 123,
"sourceId": "/meter/1",
"i": [
"watts",
"current",
"voltage",
"frequency",
"apparentPower",
"reactivePower"
],
"a": ["wattHours"]
}
]
}
Since the app only wants to display meter reading values, the accumulating properties in the metadata objects's a
property are then populated as options in the Datum Property form menu:
// create a filter with the node and source ID values from the form
const filter = new DatumFilter();
filter.nodeId = Number(nodeId);
filter.sourceId = sourceId;
// construct the API URL to call
const streamMetaUrl =
urlHelper.baseUrl() + "/datum/stream/meta/node?" + filter.toUriEncoding();
// generate authorization headers using the token credentials in the form
const headers = authorizeUrl(streamMetaUrl);
// make API request using Fetch API
const res = await fetch(streamMetaUrl, {
method: "GET",
headers: headers,
});
// wait for response
const json = await res.json();
// clear out and re-populate the property names menu
while (settingsForm.snDatumProperty.length) {
settingsForm.snDatumProperty.remove(0);
}
if (json.data.length) {
settingsForm.snDatumProperty.add(new Option("Choose...", ""));
for (const meta of json.data) {
// add all accumulating properties to menu
if (Array.isArray(meta.a)) {
for (const p of meta.a) {
settingsForm.snDatumProperty.add(new Option(p, p));
}
}
}
}
Once the date range, node, source, and property are all configured, the app can query SolarNetwork for the actual datum stream. It uses the /datum/stream/reading method to query for hourly meter readings over the given date range.
Note there are other query methods that could be used, such as /datum/stream/datum or /datum/list or /datum/reading. The choice on which to use is up to the needs of your app.
The /datum/stream/reading
API returns a result like this:
{
"success": true,
"meta": [
{
"streamId": "7714f762-2361-4ec2-98ab-7e96807b32a6",
"zone": "Pacific/Auckland",
"kind": "n",
"objectId": 123,
"sourceId": "/power/1",
"i": ["watts", "current", "voltage", "frequency"],
"a": ["wattHours"]
}
],
"data": [
[
0,
[1650667326308, null],
[12326, 600, 8290, 14222],
null,
[230.19719, 600, 228.2922, 233.12324],
[50.19501, 600, 49.94322, 50.20012],
[6472722, 2819093834849, 2819100307571]
]
]
}
The app converts each stream result object into a GeneralDatum
object that looks like this:
{
nodeId: 123,
sourceId: "/power/1",
date: Date(1650667326308),
watts:12326,
voltage:230.19719,
frequency:50.19501,
wattHours:6472722
}
The code involved looks like this:
// create filter with Hour aggregation, node/source IDs, and date range from the form
const filter = new DatumFilter();
filter.aggregation = Aggregations.Hour;
filter.nodeId = Number(nodeId);
filter.sourceId = sourceId;
if (startDate) {
startDate.setHours(0, 0, 0, 0);
filter.localStartDate = startDate;
}
if (endDate) {
endDate.setHours(0, 0, 0, 0);
filter.localEndDate = endDate;
}
// construct the API URL to call
const streamDataUrl =
urlHelper.baseUrl() +
"/datum/stream/reading?readingType=" +
DatumReadingTypes.Difference.name +
"&" +
filter.toUriEncoding();
// generate authorization headers using the token credentials in the form
const headers = authorizeUrl(streamDataUrl);
// make API request using Fetch API
const res = await fetch(streamDataUrl, {
method: "GET",
headers: headers,
});
// wait for response
const json = await res.json();
if (
!(
json &&
Array.isArray(json.data) &&
Array.isArray(json.meta) &&
json.meta.length
)
) {
return Promise.reject("No data available.");
}
// convert stream results into GeneralDatum to more easily use in charts
const result: GeneralDatum[] = [];
// create a DatumStreamMetadataRegistry to associate result objects with stream metadata
const reg = DatumStreamMetadataRegistry.fromJsonObject(json.meta);
if (!reg) {
return Promise.reject("JSON could not be parsed.");
}
for (const data of json.data) {
// get the stream metadata for this result
const meta = reg.metadataAt(data[0]);
if (!meta) {
continue;
}
// convert stream result object into GeneralDatum object
const d = datumForStreamData(data, meta)?.toObject();
if (d) {
result.push(d as GeneralDatum);
}
}
return Promise.resolve(result);
Once the list of GeneralDatum
has been obtained, an area chart is rendered using time on the x-axis and Property Name values on the y-axis. This is done using billboard.js and looks like this:
// c looks like {propName: "foo", displayName: "kWh", scale: 1}
const c = seriesConfig(config);
bb.generate({
data: {
json: datum,
keys: {
x: "date",
// render the "Datum Property" form value
value: [c.propName],
},
type: area(),
},
axis: {
x: {
type: "timeseries",
tick: {
count: 6,
fit: false,
format: "%Y-%m-%d %H:%M",
},
padding: {
left: 20,
right: 10,
unit: "px",
},
},
y: {
label: c.displayName,
tick: {
// scale the value using the "Unit scale" form value
format: function (v: number) {
return v / c.scale;
},
},
},
},
legend: {
hide: true,
},
zoom: {
enabled: zoom(),
type: "drag",
},
tooltip: {
format: {
title: tooltipDateFormat,
name: () => "Example Chart",
},
},
point: {
focus: {
only: true,
},
},
bindto: "#chart",
});
To build yourself, clone or download this repository. You need to have Node 16+ installed. Then:
# initialize dependencies
npm ci
# run development live server on http://localhost:8080
npm run dev
# build for production
npm run build
Running the build
script will generate the application into the dist/
directory.