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.
@@ -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=""eskomsepushapi>EskomSePush API</h3>
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 will fetch the data from EskomSePush every hour. As
156
- every fetch from the API takes 2 calls, the 50 free queries per day on a
157
- free account should suffice. Every ten minutes the API status is checked to
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 API can be used for searching the correct area id. Do note that this will cost some of the daily queries. If you don&#39;t want that and you already know the id of the area, fill out the area id manually.</p>
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=""inputs">Inputs</h3>
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://documenter.getpostman.com/view/1296288/UzQuNk3E">here</a>.
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
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "types": ["node", "jest"]
5
+ },
6
+ "include": ["src/**/*"],
7
+ "exclude": ["node_modules", "dist"]
8
+ }