node-red-contrib-eskomsepush 0.0.18 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +8 -0
- package/README.md +5 -3
- package/jest.config.js +9 -0
- package/package.json +14 -7
- package/src/__tests__/api.test.ts +103 -0
- package/src/__tests__/loadshedding.test.ts +262 -0
- package/src/lib/api.ts +45 -0
- package/src/lib/loadshedding.ts +131 -0
- package/src/lib/types.ts +109 -0
- package/src/nodes/eskomsepush.html +9 -8
- package/src/nodes/eskomsepush.ts +203 -0
- package/tsconfig.json +17 -0
- package/tsconfig.test.json +8 -0
- package/src/nodes/eskomsepush.js +0 -371
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export interface AllowanceInfo {
|
|
2
|
+
allowance: {
|
|
3
|
+
count: number
|
|
4
|
+
limit: number
|
|
5
|
+
type: string
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface StatusEntry {
|
|
10
|
+
name: string
|
|
11
|
+
next_stages: Array<{ stage: string; stage_start_timestamp: string }>
|
|
12
|
+
stage: string
|
|
13
|
+
stage_updated: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StatusInfo {
|
|
17
|
+
status: Record<string, StatusEntry>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AreaEvent {
|
|
21
|
+
end: string
|
|
22
|
+
note: string
|
|
23
|
+
start: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ScheduleDay {
|
|
27
|
+
date: string
|
|
28
|
+
name: string
|
|
29
|
+
stages: string[][]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AreaInfo {
|
|
33
|
+
events: AreaEvent[]
|
|
34
|
+
info: {
|
|
35
|
+
name: string
|
|
36
|
+
region: string
|
|
37
|
+
}
|
|
38
|
+
schedule: {
|
|
39
|
+
days: ScheduleDay[]
|
|
40
|
+
source: string
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AreasSearchResult {
|
|
45
|
+
areas: Array<{
|
|
46
|
+
id: string
|
|
47
|
+
name: string
|
|
48
|
+
region: string
|
|
49
|
+
}>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface NextPeriod {
|
|
53
|
+
type: 'schedule' | 'event'
|
|
54
|
+
start: number
|
|
55
|
+
end: number
|
|
56
|
+
duration: number
|
|
57
|
+
islong: boolean
|
|
58
|
+
stage: string | number
|
|
59
|
+
isHigherStage?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CalcResult {
|
|
63
|
+
sleeptime: number
|
|
64
|
+
stage: string | number
|
|
65
|
+
active: boolean
|
|
66
|
+
type?: 'schedule' | 'event'
|
|
67
|
+
start?: number
|
|
68
|
+
end?: number
|
|
69
|
+
duration?: number
|
|
70
|
+
islong?: boolean
|
|
71
|
+
secondstostatechange?: number
|
|
72
|
+
next?: NextPeriod
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface NodeConfig {
|
|
76
|
+
name?: string
|
|
77
|
+
licensekey: string
|
|
78
|
+
area: string
|
|
79
|
+
statusselect: string
|
|
80
|
+
test?: boolean
|
|
81
|
+
verbose?: boolean
|
|
82
|
+
api_allowance_buffer: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface NodeRedNode {
|
|
86
|
+
config: NodeConfig
|
|
87
|
+
status(opts: { fill?: string; shape?: string; text?: string }): void
|
|
88
|
+
warn(msg: unknown): void
|
|
89
|
+
send(msgs: (object | null)[]): void
|
|
90
|
+
on(event: 'close' | 'input', listener: (...args: unknown[]) => void): void
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface NodeRed {
|
|
94
|
+
nodes: {
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
96
|
+
createNode(node: any, config: NodeConfig): void
|
|
97
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
98
|
+
registerType(type: string, constructor: any): void
|
|
99
|
+
}
|
|
100
|
+
httpNode: {
|
|
101
|
+
get(
|
|
102
|
+
path: string,
|
|
103
|
+
handler: (
|
|
104
|
+
req: { query: Record<string, string> },
|
|
105
|
+
res: { setHeader(name: string, value: string): void; send(data: unknown): unknown }
|
|
106
|
+
) => void
|
|
107
|
+
): void
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
</script>
|
|
145
145
|
|
|
146
146
|
<script type="text/html" data-help-name="eskomsepush">
|
|
147
|
-
<h3 id=""
|
|
147
|
+
<h3 id="eskomsepushapi">EskomSePush API</h3>
|
|
148
148
|
|
|
149
149
|
<p>A node for retrieving info from the EskomSePush API.</p>
|
|
150
150
|
|
|
@@ -152,23 +152,24 @@
|
|
|
152
152
|
in South Africa as easy as possible.</p>
|
|
153
153
|
<p>First you need to configure the node by entering the license key and entering
|
|
154
154
|
the correct area.</p>
|
|
155
|
-
<p>Once deployed, the node
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
see how many queries you have left.</p>
|
|
155
|
+
<p>Once deployed, the node fetches data from EskomSePush at a dynamic interval
|
|
156
|
+
calculated from your remaining daily API allowance — never more often than needed.
|
|
157
|
+
Every fetch of status and schedule takes 2 API calls. Every ten minutes the
|
|
158
|
+
allowance is checked to see how many queries you have left.</p>
|
|
159
159
|
<p>Internally the node checks every minute if a schedule is currently active or not. It
|
|
160
160
|
will also output a message on the first deployment.</p>
|
|
161
161
|
|
|
162
162
|
<h3 id="configuration">Configuration</h3>
|
|
163
163
|
|
|
164
164
|
<p>First you will need a <em>licence key</em>. You can get one from <a href="https://eskomsepush.gumroad.com/l/api">here</a>, by subsribing to the Free model. Note that this is for personal use only.</p>
|
|
165
|
-
<p>Next you need to insert the correct area. Once a valid license is filled out, the
|
|
165
|
+
<p>Next you need to insert the correct area id. Once a valid license is filled out, the search form below can help find it — note that each search costs API quota. If you already know the id, fill it out manually.</p>
|
|
166
|
+
<p>Use the short form of the area id: <code>eskmo-15</code>, not <code>eskmo-15-ballitokwadukuzakwazulunatal</code>. If you have an old-style id with a location suffix, the node will still work but will log a warning asking you to update it.</p>
|
|
166
167
|
<p>Then you need to fill out which status to follow. This can be either <em>National</em> (eskom) or <em>Capetown</em>.</p>
|
|
167
168
|
<p>The <i>API allowance buffer</i> allows you to reserve some daily unused API calls (so you can use your API key for other integrations too). If you don't use that, set this to _0_ (zero).</p>
|
|
168
169
|
<p>If the <em>test</em> checkbox has been selected, test data for the area will be fetched instead of the actual schedule. This is useful when debugging.</p>
|
|
169
170
|
<p>The <em>verbose</em> checkbox will give some additional <tt>node.warn()</tt> messages, appearing in the debug tab. This is also useful when debugging.</p>
|
|
170
171
|
|
|
171
|
-
<h3 id="
|
|
172
|
+
<h3 id="inputs">Inputs</h3>
|
|
172
173
|
|
|
173
174
|
<p>
|
|
174
175
|
The input side is not needed in most cases. The node will output its status every ten minutes and won't update the information it gets from the API more
|
|
@@ -253,7 +254,7 @@ a matching <em>schedule</em>.
|
|
|
253
254
|
|
|
254
255
|
<h1 id="documentation">Documentation</h1>
|
|
255
256
|
|
|
256
|
-
<p>The GitHub site for the node can be found <a href="https://github.com/dirkjanfaber/node-red-contrib-eskomsepush">here</a>, while the documentation for the API can be found <a href="https://
|
|
257
|
+
<p>The GitHub site for the node can be found <a href="https://github.com/dirkjanfaber/node-red-contrib-eskomsepush">here</a>, while the documentation for the API can be found <a href="https://developer.sepush.co.za/business/3.0/">here</a>.
|
|
257
258
|
For issues and/or suggestions for improving the node, please use the <a href="https://github.com/dirkjanfaber/node-red-contrib-eskomsepush/issues">issue tracker</a>.</p>
|
|
258
259
|
</script>
|
|
259
260
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { fetchAllowance, fetchArea, fetchStatus, migrateAreaId, searchAreas } from '../lib/api'
|
|
2
|
+
import { calculateCalc, calculateSleepTime, getMinutesToAPIReset } from '../lib/loadshedding'
|
|
3
|
+
import type {
|
|
4
|
+
AllowanceInfo,
|
|
5
|
+
AreaInfo,
|
|
6
|
+
CalcResult,
|
|
7
|
+
NodeConfig,
|
|
8
|
+
NodeRed,
|
|
9
|
+
NodeRedNode,
|
|
10
|
+
StatusInfo
|
|
11
|
+
} from '../lib/types'
|
|
12
|
+
|
|
13
|
+
module.exports = function (RED: NodeRed) {
|
|
14
|
+
'use strict'
|
|
15
|
+
|
|
16
|
+
function EskomSePush(this: NodeRedNode, config: NodeConfig) {
|
|
17
|
+
RED.nodes.createNode(this, config)
|
|
18
|
+
this.config = config
|
|
19
|
+
|
|
20
|
+
const node = this
|
|
21
|
+
|
|
22
|
+
// Per-instance state — avoids shared-state bugs when multiple nodes exist in the same flow
|
|
23
|
+
const info = {
|
|
24
|
+
api: { lastUpdate: null as Date | null, info: {} as Partial<AllowanceInfo> },
|
|
25
|
+
status: { lastUpdate: null as Date | null, info: {} as Partial<StatusInfo> },
|
|
26
|
+
area: { lastUpdate: null as Date | null, info: {} as Partial<AreaInfo> },
|
|
27
|
+
calc: {} as Partial<CalcResult>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function updateSheddingStatus(msg?: { payload?: unknown }) {
|
|
31
|
+
const now = new Date()
|
|
32
|
+
|
|
33
|
+
// Refresh allowance every 10 minutes or on demand
|
|
34
|
+
if (msg?.payload === 'allowance' ||
|
|
35
|
+
info.api.lastUpdate === null ||
|
|
36
|
+
(now.getTime() - info.api.lastUpdate.getTime()) > 600_000) {
|
|
37
|
+
try {
|
|
38
|
+
if (node.config.verbose) node.warn('Running fetchAllowance')
|
|
39
|
+
info.api.info = await fetchAllowance(node.config.licensekey)
|
|
40
|
+
info.api.lastUpdate = now
|
|
41
|
+
} catch (err) {
|
|
42
|
+
node.warn({ error: (err as Error).message })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (Object.keys(info.api.info).length === 0) {
|
|
47
|
+
node.warn('No API info (yet), refusing to continue')
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const allowance = (info.api.info as AllowanceInfo).allowance
|
|
52
|
+
if (allowance.count >= allowance.limit) {
|
|
53
|
+
node.warn('No API calls left, not checking status/schedule')
|
|
54
|
+
node.status({ fill: 'red', shape: 'ring', text: 'API quota reached' })
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const minutesToReset = getMinutesToAPIReset(now)
|
|
59
|
+
info.calc.sleeptime = calculateSleepTime(
|
|
60
|
+
allowance.limit,
|
|
61
|
+
allowance.count,
|
|
62
|
+
node.config.api_allowance_buffer,
|
|
63
|
+
minutesToReset
|
|
64
|
+
)
|
|
65
|
+
if (node.config.verbose) node.warn('Calculated sleeptime: ' + info.calc.sleeptime)
|
|
66
|
+
|
|
67
|
+
const sleepMs = info.calc.sleeptime * 60_000
|
|
68
|
+
|
|
69
|
+
const { id: resolvedAreaId, migrated } = migrateAreaId(node.config.area)
|
|
70
|
+
if (migrated) {
|
|
71
|
+
node.warn(
|
|
72
|
+
`Area ID "${node.config.area}" is a v2-style ID. ` +
|
|
73
|
+
`Using "${resolvedAreaId}" for the v3 API. ` +
|
|
74
|
+
`Please update the area ID in the node configuration.`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Refresh stage on demand or when sleeptime has elapsed
|
|
79
|
+
if (msg?.payload === 'stage' ||
|
|
80
|
+
info.status.lastUpdate === null ||
|
|
81
|
+
(now.getTime() - info.status.lastUpdate.getTime()) > sleepMs) {
|
|
82
|
+
try {
|
|
83
|
+
if (node.config.verbose) {
|
|
84
|
+
node.warn(
|
|
85
|
+
info.status.lastUpdate === null
|
|
86
|
+
? 'Running fetchStatus - initial run'
|
|
87
|
+
: `Running fetchStatus after ${((now.getTime() - info.status.lastUpdate.getTime()) / 60_000).toFixed(0)} minutes`
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'Fetching status' })
|
|
91
|
+
info.status.info = await fetchStatus(node.config.licensekey)
|
|
92
|
+
info.status.lastUpdate = now
|
|
93
|
+
} catch (err) {
|
|
94
|
+
node.warn({ error: (err as Error).message })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Refresh area schedule on demand or when sleeptime has elapsed
|
|
99
|
+
if (msg?.payload === 'area' ||
|
|
100
|
+
info.area.lastUpdate === null ||
|
|
101
|
+
(now.getTime() - info.area.lastUpdate.getTime()) > sleepMs) {
|
|
102
|
+
try {
|
|
103
|
+
if (node.config.verbose) {
|
|
104
|
+
node.warn(
|
|
105
|
+
info.area.lastUpdate === null
|
|
106
|
+
? 'Running fetchArea - initial run'
|
|
107
|
+
: `Running fetchArea after ${((now.getTime() - info.area.lastUpdate.getTime()) / 60_000).toFixed(0)} minutes`
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'Fetching schedule' })
|
|
111
|
+
info.area.info = await fetchArea(node.config.licensekey, resolvedAreaId, node.config.test)
|
|
112
|
+
info.area.lastUpdate = now
|
|
113
|
+
} catch (err) {
|
|
114
|
+
node.warn({ error: (err as Error).message })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!info.api.lastUpdate || !info.status.lastUpdate || !info.area.lastUpdate) {
|
|
119
|
+
if (node.config.verbose) node.warn('Not enough info to continue')
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const calc = calculateCalc(
|
|
124
|
+
info.area.info as AreaInfo,
|
|
125
|
+
info.status.info as StatusInfo,
|
|
126
|
+
node.config.statusselect,
|
|
127
|
+
info.calc.sleeptime,
|
|
128
|
+
now
|
|
129
|
+
)
|
|
130
|
+
info.calc = calc
|
|
131
|
+
|
|
132
|
+
if (node.config.verbose) node.warn(calc)
|
|
133
|
+
|
|
134
|
+
node.send([
|
|
135
|
+
{
|
|
136
|
+
payload: calc.active,
|
|
137
|
+
stage: calc.stage,
|
|
138
|
+
statusselect: node.config.statusselect,
|
|
139
|
+
api: { count: allowance.count, limit: allowance.limit },
|
|
140
|
+
calc
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
stage: info.status,
|
|
144
|
+
schedule: info.area
|
|
145
|
+
}
|
|
146
|
+
])
|
|
147
|
+
|
|
148
|
+
const fill = calc.active ? 'yellow' : 'green'
|
|
149
|
+
const shape = calc.active && calc.type === 'event' ? 'dot' : 'ring'
|
|
150
|
+
let statusText = `Stage ${calc.stage}: `
|
|
151
|
+
|
|
152
|
+
if (calc.active && calc.start !== undefined && calc.end !== undefined) {
|
|
153
|
+
statusText += new Date(calc.start).toLocaleTimeString([], { timeStyle: 'short' })
|
|
154
|
+
statusText += ' - ' + new Date(calc.end).toLocaleTimeString([], { timeStyle: 'short' })
|
|
155
|
+
} else if (calc.next) {
|
|
156
|
+
const nextDate = new Date(calc.next.start)
|
|
157
|
+
// Fix: original code compared getUTCDay() (0–6) with getUTCDate() (1–31), always unequal
|
|
158
|
+
if (nextDate.toDateString() !== now.toDateString()) {
|
|
159
|
+
statusText += nextDate.toLocaleString([], { weekday: 'short' }) + ' '
|
|
160
|
+
}
|
|
161
|
+
statusText += nextDate.toLocaleTimeString([], { timeStyle: 'short' })
|
|
162
|
+
statusText += ' - ' + new Date(calc.next.end).toLocaleTimeString([], { timeStyle: 'short' })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
statusText += ` (API: ${allowance.count}/${allowance.limit})`
|
|
166
|
+
node.status({ fill, shape, text: statusText })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
updateSheddingStatus()
|
|
170
|
+
const intervalId = setInterval(() => updateSheddingStatus(), 60_000)
|
|
171
|
+
|
|
172
|
+
node.on('input', (msg) => updateSheddingStatus(msg as { payload?: unknown }))
|
|
173
|
+
node.on('close', () => clearInterval(intervalId))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
RED.nodes.registerType('eskomsepush', EskomSePush)
|
|
177
|
+
|
|
178
|
+
RED.httpNode.get('/eskomsepush/search', async (req, res) => {
|
|
179
|
+
res.setHeader('Content-Type', 'application/json')
|
|
180
|
+
if (!req.query?.token || !req.query?.search) {
|
|
181
|
+
return res.send(JSON.stringify({ error: 'invalid' }))
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const data = await searchAreas(req.query.token, req.query.search)
|
|
185
|
+
return res.send(data)
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return res.send({ error: (err as Error).message })
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
RED.httpNode.get('/eskomsepush/api', async (req, res) => {
|
|
192
|
+
res.setHeader('Content-Type', 'application/json')
|
|
193
|
+
if (!req.query?.token) {
|
|
194
|
+
return res.send(JSON.stringify({ error: 'invalid' }))
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const data = await fetchAllowance(req.query.token)
|
|
198
|
+
return res.send(data)
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return res.send({ error: (err as Error).message })
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2020"],
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"sourceMap": true,
|
|
13
|
+
"types": ["node"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"],
|
|
16
|
+
"exclude": ["node_modules", "dist", "src/**/__tests__/**"]
|
|
17
|
+
}
|