node-red-contrib-eskomsepush 0.0.18 → 0.1.0

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,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm run *)",
5
+ "Bash(npx jest *)"
6
+ ]
7
+ }
8
+ }
package/jest.config.js ADDED
@@ -0,0 +1,9 @@
1
+ /** @type {import('jest').Config} */
2
+ module.exports = {
3
+ testEnvironment: 'node',
4
+ testMatch: ['**/src/__tests__/**/*.test.ts'],
5
+ collectCoverageFrom: ['src/lib/**/*.ts'],
6
+ transform: {
7
+ '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.test.json' }]
8
+ }
9
+ }
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "node-red-contrib-eskomsepush",
3
- "version": "0.0.18",
3
+ "version": "0.1.0",
4
4
  "description": "Node-RED interface for the Eskomsepush API",
5
- "main": "index.js",
6
5
  "scripts": {
7
- "test": "standard --fix"
6
+ "build": "tsc && cp src/nodes/eskomsepush.html dist/nodes/ && cp -r src/nodes/icons dist/nodes/",
7
+ "prepare": "npm run build",
8
+ "test": "jest",
9
+ "test:coverage": "jest --coverage",
10
+ "clean": "rm -rf dist"
8
11
  },
9
12
  "keywords": [
10
13
  "node-red",
@@ -19,7 +22,7 @@
19
22
  "license": "MIT",
20
23
  "node-red": {
21
24
  "nodes": {
22
- "eskomsepush": "./src/nodes/eskomsepush.js"
25
+ "eskomsepush": "./dist/nodes/eskomsepush.js"
23
26
  },
24
27
  "version": ">=3.0.2"
25
28
  },
@@ -28,12 +31,16 @@
28
31
  "url": "https://github.com/dirkjanfaber/node-red-contrib-eskomsepush"
29
32
  },
30
33
  "dependencies": {
31
- "axios": "^1.3.5"
34
+ "axios": "^1.17.0"
32
35
  },
33
36
  "devDependencies": {
34
- "standard": "^17.0.0"
37
+ "@types/jest": "^29.5.14",
38
+ "@types/node": "^25.9.3",
39
+ "jest": "^29.7.0",
40
+ "ts-jest": "^29.4.11",
41
+ "typescript": "^6.0.3"
35
42
  },
36
43
  "engines": {
37
- "node": ">=14.17.4"
44
+ "node": ">=18.0.0"
38
45
  }
39
46
  }
@@ -0,0 +1,103 @@
1
+ import axios from 'axios'
2
+ import { fetchAllowance, fetchArea, fetchStatus, migrateAreaId, searchAreas } from '../lib/api'
3
+
4
+ jest.mock('axios')
5
+ const mockedAxios = axios as jest.Mocked<typeof axios>
6
+
7
+ const V3_BASE = 'https://developer.sepush.co.za/business/3.0'
8
+ const TOKEN = 'TEST-TOKEN-1234'
9
+
10
+ beforeEach(() => jest.clearAllMocks())
11
+
12
+ describe('migrateAreaId', () => {
13
+ it('strips the location suffix from a v2-style ID', () => {
14
+ expect(migrateAreaId('eskde-10-fourways')).toEqual({ id: 'eskde-10', migrated: true })
15
+ })
16
+
17
+ it('leaves a v3-style ID unchanged', () => {
18
+ expect(migrateAreaId('eskde-10')).toEqual({ id: 'eskde-10', migrated: false })
19
+ })
20
+
21
+ it('handles long location suffixes', () => {
22
+ expect(migrateAreaId('eskmo-15-ballitokwadukuzakwazulunatal')).toEqual({
23
+ id: 'eskmo-15',
24
+ migrated: true
25
+ })
26
+ })
27
+ })
28
+
29
+ describe('fetchAllowance', () => {
30
+ it('calls the v3 api_allowance endpoint', async () => {
31
+ const payload = { allowance: { count: 10, limit: 50, type: 'free' } }
32
+ mockedAxios.get.mockResolvedValueOnce({ data: payload })
33
+
34
+ const result = await fetchAllowance(TOKEN)
35
+
36
+ expect(mockedAxios.get).toHaveBeenCalledWith(
37
+ `${V3_BASE}/api_allowance`,
38
+ expect.objectContaining({ headers: { token: TOKEN } })
39
+ )
40
+ expect(result).toEqual(payload)
41
+ })
42
+ })
43
+
44
+ describe('fetchStatus', () => {
45
+ it('calls the v3 status endpoint', async () => {
46
+ const payload = { status: { eskom: { stage: '2', name: 'National', next_stages: [], stage_updated: '' } } }
47
+ mockedAxios.get.mockResolvedValueOnce({ data: payload })
48
+
49
+ const result = await fetchStatus(TOKEN)
50
+
51
+ expect(mockedAxios.get).toHaveBeenCalledWith(
52
+ `${V3_BASE}/status`,
53
+ expect.objectContaining({ headers: { token: TOKEN } })
54
+ )
55
+ expect(result).toEqual(payload)
56
+ })
57
+ })
58
+
59
+ describe('fetchArea', () => {
60
+ it('calls the v3 area endpoint with the given area ID', async () => {
61
+ const payload = { events: [], info: { name: 'Test', region: 'Test' }, schedule: { days: [], source: '' } }
62
+ mockedAxios.get.mockResolvedValueOnce({ data: payload })
63
+
64
+ await fetchArea(TOKEN, 'eskde-10')
65
+
66
+ expect(mockedAxios.get).toHaveBeenCalledWith(
67
+ `${V3_BASE}/area`,
68
+ expect.objectContaining({
69
+ headers: { token: TOKEN },
70
+ params: { id: 'eskde-10' }
71
+ })
72
+ )
73
+ })
74
+
75
+ it('appends test=current when testMode is true', async () => {
76
+ mockedAxios.get.mockResolvedValueOnce({ data: {} })
77
+
78
+ await fetchArea(TOKEN, 'eskde-10', true)
79
+
80
+ expect(mockedAxios.get).toHaveBeenCalledWith(
81
+ `${V3_BASE}/area`,
82
+ expect.objectContaining({ params: { id: 'eskde-10', test: 'current' } })
83
+ )
84
+ })
85
+ })
86
+
87
+ describe('searchAreas', () => {
88
+ it('calls the v3 areas_search endpoint with text param', async () => {
89
+ const payload = { areas: [{ id: 'eskde-10', name: 'Fourways 2', region: 'Eskom Direct' }] }
90
+ mockedAxios.get.mockResolvedValueOnce({ data: payload })
91
+
92
+ const result = await searchAreas(TOKEN, 'fourways')
93
+
94
+ expect(mockedAxios.get).toHaveBeenCalledWith(
95
+ `${V3_BASE}/areas_search`,
96
+ expect.objectContaining({
97
+ headers: { token: TOKEN },
98
+ params: { text: 'fourways' }
99
+ })
100
+ )
101
+ expect(result).toEqual(payload)
102
+ })
103
+ })
@@ -0,0 +1,262 @@
1
+ import { calculateCalc, calculateSleepTime, getMinutesToAPIReset } from '../lib/loadshedding'
2
+ import type { AreaInfo, StatusInfo } from '../lib/types'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+
8
+ function makeArea(
9
+ stageSlots: string[][],
10
+ tomorrowSlots: string[][] = stageSlots,
11
+ events: AreaInfo['events'] = []
12
+ ): AreaInfo {
13
+ return {
14
+ events,
15
+ info: { name: 'Test Area', region: 'Test Region' },
16
+ schedule: {
17
+ days: [
18
+ { date: '2024-01-15', name: 'Monday', stages: stageSlots },
19
+ { date: '2024-01-16', name: 'Tuesday', stages: tomorrowSlots }
20
+ ],
21
+ source: 'test'
22
+ }
23
+ }
24
+ }
25
+
26
+ function makeStatus(stage: string, key = 'eskom'): StatusInfo {
27
+ return { status: { [key]: { stage, name: 'National', next_stages: [], stage_updated: '' } } }
28
+ }
29
+
30
+ /** Local Date at a specific clock time on 2024-01-15 */
31
+ function at(h: number, m = 0): Date {
32
+ return new Date(2024, 0, 15, h, m, 0)
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // getMinutesToAPIReset
37
+ // ---------------------------------------------------------------------------
38
+
39
+ describe('getMinutesToAPIReset', () => {
40
+ it('returns 60 minutes when it is 01:00', () => {
41
+ expect(getMinutesToAPIReset(at(1))).toBe(60)
42
+ })
43
+
44
+ it('returns 23 * 60 minutes when it is 03:00 (reset already passed today)', () => {
45
+ expect(getMinutesToAPIReset(at(3))).toBe(23 * 60)
46
+ })
47
+
48
+ it('returns 0 minutes at exactly 02:00', () => {
49
+ expect(getMinutesToAPIReset(at(2))).toBe(0)
50
+ })
51
+ })
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // calculateSleepTime
55
+ // ---------------------------------------------------------------------------
56
+
57
+ describe('calculateSleepTime', () => {
58
+ it('returns 60 when all allowance is consumed', () => {
59
+ expect(calculateSleepTime(50, 50, 0, 120)).toBe(60)
60
+ })
61
+
62
+ it('returns 60 when remaining is negative due to buffer', () => {
63
+ expect(calculateSleepTime(50, 48, 5, 120)).toBe(60)
64
+ })
65
+
66
+ it('enforces a minimum of 10 minutes', () => {
67
+ // remaining=50, minutesToReset=1 → round(1/ceil(50/2)) = round(1/25) = 0 → min 10
68
+ expect(calculateSleepTime(50, 0, 0, 1)).toBe(10)
69
+ })
70
+
71
+ it('distributes remaining calls evenly over minutesToReset', () => {
72
+ // remaining=20, minutesToReset=120 → round(120/ceil(20/2)) = round(120/10) = 12
73
+ expect(calculateSleepTime(50, 30, 0, 120)).toBe(12)
74
+ })
75
+
76
+ it('applies buffer when calculating remaining calls', () => {
77
+ // remaining=15 (50-30-5), minutesToReset=120 → round(120/ceil(15/2)) = round(120/8) = 15
78
+ expect(calculateSleepTime(50, 30, 5, 120)).toBe(15)
79
+ })
80
+ })
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // calculateCalc
84
+ // ---------------------------------------------------------------------------
85
+
86
+ describe('calculateCalc', () => {
87
+ const SLEEPTIME = 60
88
+
89
+ describe('stage 0 (no loadshedding)', () => {
90
+ it('returns inactive with no next period', () => {
91
+ const area = makeArea([[], []])
92
+ const status = makeStatus('0')
93
+ const result = calculateCalc(area, status, 'eskom', SLEEPTIME, at(13))
94
+
95
+ expect(result.active).toBe(false)
96
+ expect(result.next).toBeUndefined()
97
+ })
98
+ })
99
+
100
+ describe('schedule-based loadshedding', () => {
101
+ // Stage 2 slots on 2024-01-15: 04:00–06:30 and 12:00–14:30
102
+ // Stage 1 slots are in index 0, stage 2 in index 1
103
+ const stageSlots = [
104
+ ['20:00-22:30'], // stage 1
105
+ ['04:00-06:30', '12:00-14:30'] // stage 2
106
+ ]
107
+ const tomorrowSlots = [
108
+ ['00:00-02:30'],
109
+ ['00:00-02:30', '08:00-10:30']
110
+ ]
111
+
112
+ it('detects an active schedule slot', () => {
113
+ const area = makeArea(stageSlots, tomorrowSlots)
114
+ const result = calculateCalc(area, makeStatus('2'), 'eskom', SLEEPTIME, at(13))
115
+
116
+ expect(result.active).toBe(true)
117
+ expect(result.type).toBe('schedule')
118
+ expect(result.stage).toBe('2')
119
+ expect(result.duration).toBe(2.5 * 3600)
120
+ expect(result.islong).toBe(false)
121
+ })
122
+
123
+ it('finds the next slot later today when not active', () => {
124
+ const area = makeArea(stageSlots, tomorrowSlots)
125
+ const result = calculateCalc(area, makeStatus('2'), 'eskom', SLEEPTIME, at(8))
126
+
127
+ expect(result.active).toBe(false)
128
+ expect(result.next?.type).toBe('schedule')
129
+ expect(result.next?.duration).toBe(2.5 * 3600)
130
+ expect(result.next?.islong).toBe(false)
131
+ // secondstostatechange should be positive (time until 12:00)
132
+ expect(result.secondstostatechange).toBeGreaterThan(0)
133
+ })
134
+
135
+ it('falls back to the first slot tomorrow when today has no more slots', () => {
136
+ const area = makeArea(stageSlots, tomorrowSlots)
137
+ const result = calculateCalc(area, makeStatus('2'), 'eskom', SLEEPTIME, at(23))
138
+
139
+ expect(result.active).toBe(false)
140
+ expect(result.next?.type).toBe('schedule')
141
+ // The next start should be on 2024-01-16 (tomorrow)
142
+ expect(new Date(result.next!.start).getDate()).toBe(16)
143
+ })
144
+
145
+ it('marks islong true for a 4-hour-plus slot', () => {
146
+ const longSlots = [
147
+ ['12:00-16:00'] // stage 1, exactly 4 h
148
+ ]
149
+ const area = makeArea(longSlots)
150
+ const result = calculateCalc(area, makeStatus('1'), 'eskom', SLEEPTIME, at(10))
151
+
152
+ expect(result.next?.islong).toBe(true)
153
+ })
154
+
155
+ it('handles midnight-crossing slots', () => {
156
+ const midnightSlots = [
157
+ ['22:00-00:30'] // stage 1, crosses midnight
158
+ ]
159
+ const area = makeArea(midnightSlots, midnightSlots)
160
+ const result = calculateCalc(area, makeStatus('1'), 'eskom', SLEEPTIME, at(23))
161
+
162
+ expect(result.active).toBe(true)
163
+ expect(result.type).toBe('schedule')
164
+ })
165
+
166
+ it('sets secondstostatechange to time until shedding ends when active', () => {
167
+ const area = makeArea(stageSlots, tomorrowSlots)
168
+ const result = calculateCalc(area, makeStatus('2'), 'eskom', SLEEPTIME, at(13))
169
+
170
+ // 14:30 − 13:00 = 90 minutes = 5400 seconds
171
+ expect(result.secondstostatechange).toBeCloseTo(5400, -1)
172
+ })
173
+
174
+ it('marks isHigherStage true when the next event has a higher stage than current', () => {
175
+ // We need the schedule loop to NOT overwrite calc.next, so we use an area
176
+ // whose stages array has only 1 element — stage 2 (index 1) is out of bounds → break.
177
+ const eventStartMs = Date.UTC(2024, 0, 15, 20, 0)
178
+ const eventEndMs = Date.UTC(2024, 0, 15, 22, 30)
179
+ const events: AreaInfo['events'] = [{
180
+ start: new Date(eventStartMs).toISOString(),
181
+ end: new Date(eventEndMs).toISOString(),
182
+ note: 'Stage 4 Loadshedding'
183
+ }]
184
+ const area = makeArea([['20:00-22:30']], [['00:00-02:30']], events)
185
+ const result = calculateCalc(area, makeStatus('2'), 'eskom', SLEEPTIME, at(10))
186
+
187
+ expect(result.next?.type).toBe('event')
188
+ expect(result.next?.stage).toBe('4')
189
+ expect(result.next?.isHigherStage).toBe(true)
190
+ })
191
+ })
192
+
193
+ describe('event-based loadshedding', () => {
194
+ // Events use ISO timestamps with timezone offset — Date.parse handles them in UTC
195
+ const eventStartMs = Date.UTC(2024, 0, 15, 10, 0) // 10:00 UTC
196
+ const eventEndMs = Date.UTC(2024, 0, 15, 12, 30) // 12:30 UTC
197
+ const events: AreaInfo['events'] = [{
198
+ start: new Date(eventStartMs).toISOString(),
199
+ end: new Date(eventEndMs).toISOString(),
200
+ note: 'Stage 2 Loadshedding'
201
+ }]
202
+
203
+ it('detects an active event', () => {
204
+ const area = makeArea([[]], [[]], events)
205
+ const nowUTC = new Date(Date.UTC(2024, 0, 15, 11, 0)) // 11:00 UTC, inside event
206
+
207
+ const result = calculateCalc(area, makeStatus('0'), 'eskom', SLEEPTIME, nowUTC)
208
+
209
+ expect(result.active).toBe(true)
210
+ expect(result.type).toBe('event')
211
+ expect(result.stage).toBe('2') // stage overridden from event note
212
+ })
213
+
214
+ it('finds an upcoming event when not yet started', () => {
215
+ const area = makeArea([[]], [[]], events)
216
+ const nowUTC = new Date(Date.UTC(2024, 0, 15, 8, 0)) // 08:00 UTC, before event
217
+
218
+ const result = calculateCalc(area, makeStatus('0'), 'eskom', SLEEPTIME, nowUTC)
219
+
220
+ expect(result.active).toBe(false)
221
+ expect(result.next?.type).toBe('event')
222
+ expect(result.next?.duration).toBe(2.5 * 3600)
223
+ })
224
+
225
+ it('ignores a past event', () => {
226
+ const area = makeArea([[]], [[]], events)
227
+ const nowUTC = new Date(Date.UTC(2024, 0, 15, 13, 0)) // 13:00 UTC, after event
228
+
229
+ const result = calculateCalc(area, makeStatus('0'), 'eskom', SLEEPTIME, nowUTC)
230
+
231
+ expect(result.active).toBe(false)
232
+ expect(result.next).toBeUndefined()
233
+ })
234
+
235
+ it('does not crash when event note has no stage pattern', () => {
236
+ const bareEvents: AreaInfo['events'] = [{
237
+ start: new Date(eventStartMs).toISOString(),
238
+ end: new Date(eventEndMs).toISOString(),
239
+ note: 'Unplanned outage'
240
+ }]
241
+ const area = makeArea([[]], [[]], bareEvents)
242
+ const nowUTC = new Date(Date.UTC(2024, 0, 15, 11, 0))
243
+
244
+ expect(() => calculateCalc(area, makeStatus('1'), 'eskom', SLEEPTIME, nowUTC)).not.toThrow()
245
+ })
246
+ })
247
+
248
+ describe('statusselect', () => {
249
+ it('reads the stage from the correct statusselect key', () => {
250
+ const statusInfo: StatusInfo = {
251
+ status: {
252
+ eskom: { stage: '1', name: 'National', next_stages: [], stage_updated: '' },
253
+ capetown: { stage: '3', name: 'Cape Town', next_stages: [], stage_updated: '' }
254
+ }
255
+ }
256
+ const area = makeArea([['20:00-22:30'], [], ['12:00-14:30', '20:00-22:30']])
257
+ const result = calculateCalc(area, statusInfo, 'capetown', SLEEPTIME, at(8))
258
+
259
+ expect(result.stage).toBe('3')
260
+ })
261
+ })
262
+ })
package/src/lib/api.ts ADDED
@@ -0,0 +1,45 @@
1
+ import axios from 'axios'
2
+ import type { AllowanceInfo, AreaInfo, AreasSearchResult, StatusInfo } from './types'
3
+
4
+ const API_BASE = 'https://developer.sepush.co.za/business/3.0'
5
+
6
+ /** Detects a v2-style area ID (e.g. eskde-10-fourways) and returns the v3 equivalent (eskde-10). */
7
+ export function migrateAreaId(areaId: string): { id: string; migrated: boolean } {
8
+ const parts = areaId.split('-')
9
+ if (parts.length > 2) {
10
+ return { id: parts.slice(0, 2).join('-'), migrated: true }
11
+ }
12
+ return { id: areaId, migrated: false }
13
+ }
14
+
15
+ export async function fetchAllowance(token: string): Promise<AllowanceInfo> {
16
+ const { data } = await axios.get<AllowanceInfo>(`${API_BASE}/api_allowance`, {
17
+ headers: { token }
18
+ })
19
+ return data
20
+ }
21
+
22
+ export async function fetchStatus(token: string): Promise<StatusInfo> {
23
+ const { data } = await axios.get<StatusInfo>(`${API_BASE}/status`, {
24
+ headers: { token }
25
+ })
26
+ return data
27
+ }
28
+
29
+ export async function fetchArea(token: string, areaId: string, testMode = false): Promise<AreaInfo> {
30
+ const params: Record<string, string> = { id: areaId }
31
+ if (testMode) params.test = 'current'
32
+ const { data } = await axios.get<AreaInfo>(`${API_BASE}/area`, {
33
+ headers: { token },
34
+ params
35
+ })
36
+ return data
37
+ }
38
+
39
+ export async function searchAreas(token: string, query: string): Promise<AreasSearchResult> {
40
+ const { data } = await axios.get<AreasSearchResult>(`${API_BASE}/areas_search`, {
41
+ headers: { token },
42
+ params: { text: query }
43
+ })
44
+ return data
45
+ }
@@ -0,0 +1,131 @@
1
+ import type { AreaInfo, CalcResult, StatusInfo } from './types'
2
+
3
+ export function getMinutesToAPIReset(now: Date = new Date()): number {
4
+ const resetTime = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 2, 0, 0)
5
+ if (now > resetTime) {
6
+ resetTime.setDate(resetTime.getDate() + 1)
7
+ }
8
+ return Math.floor((resetTime.getTime() - now.getTime()) / 60_000)
9
+ }
10
+
11
+ export function calculateSleepTime(
12
+ limit: number,
13
+ count: number,
14
+ buffer: number,
15
+ minutesToReset: number
16
+ ): number {
17
+ const remaining = limit - buffer - count
18
+ if (remaining <= 0) return 60
19
+ const sleeptime = Math.round(minutesToReset / Math.ceil(remaining / 2))
20
+ return Math.max(sleeptime, 10)
21
+ }
22
+
23
+ function parseScheduleTime(date: string, time: string): number {
24
+ const [year, month, day] = date.split('-').map(Number)
25
+ const [hour, minute] = time.split(':').map(Number)
26
+ return new Date(year, month - 1, day, hour, minute).getTime()
27
+ }
28
+
29
+ export function calculateCalc(
30
+ areaInfo: AreaInfo,
31
+ statusInfo: StatusInfo,
32
+ statusselect: string,
33
+ sleeptime: number,
34
+ now: Date = new Date()
35
+ ): CalcResult {
36
+ const statusStage = statusInfo.status[statusselect]?.stage ?? '0'
37
+ const calc: CalcResult = {
38
+ sleeptime,
39
+ stage: statusStage,
40
+ active: false
41
+ }
42
+
43
+ // Check events first — an active event can override the effective stage
44
+ if (areaInfo.events.length > 0) {
45
+ const firstEvent = areaInfo.events[0]
46
+ const eventStart = Date.parse(firstEvent.start)
47
+ const eventEnd = Date.parse(firstEvent.end)
48
+
49
+ if (now.getTime() >= eventStart && now.getTime() < eventEnd) {
50
+ calc.type = 'event'
51
+ calc.active = true
52
+ const stageMatch = firstEvent.note.match(/Stage (\d+)/i)
53
+ if (stageMatch) calc.stage = stageMatch[1]
54
+ calc.start = eventStart
55
+ calc.end = eventEnd
56
+ } else if (now.getTime() < eventStart) {
57
+ const stageMatch = firstEvent.note.match(/Stage (\d+)/i)
58
+ calc.next = {
59
+ type: 'event',
60
+ start: eventStart,
61
+ end: eventEnd,
62
+ duration: 0, // computed in post-loop block
63
+ islong: false, // computed in post-loop block
64
+ stage: stageMatch ? stageMatch[1] : statusStage
65
+ }
66
+ }
67
+ }
68
+
69
+ // Check scheduled downtime — uses local time (schedule times have no timezone info)
70
+ const effectiveStageNum = parseInt(String(calc.stage), 10)
71
+ let breakLoop = false
72
+
73
+ for (const day of areaInfo.schedule.days) {
74
+ const stageIndex = effectiveStageNum - 1
75
+
76
+ if (stageIndex < 0 || stageIndex >= day.stages.length) {
77
+ break // Stage 0 or out of range → no scheduled loadshedding
78
+ }
79
+
80
+ const slots = day.stages[stageIndex]
81
+ if (!Array.isArray(slots)) break
82
+
83
+ for (const slot of slots) {
84
+ const [startStr, endStr] = slot.split('-')
85
+ let schedStart = parseScheduleTime(day.date, startStr)
86
+ let schedEnd = parseScheduleTime(day.date, endStr)
87
+
88
+ // Handle slots that cross midnight (e.g. "22:00-00:30")
89
+ if (schedEnd < schedStart) {
90
+ schedEnd += 24 * 60 * 60 * 1000
91
+ }
92
+
93
+ if (now.getTime() < schedEnd) {
94
+ breakLoop = true
95
+ if (now.getTime() >= schedStart) {
96
+ calc.active = true
97
+ calc.type = 'schedule'
98
+ calc.start = schedStart
99
+ calc.end = schedEnd
100
+ } else {
101
+ calc.next = {
102
+ type: 'schedule',
103
+ start: schedStart,
104
+ end: schedEnd,
105
+ duration: 0, // computed below
106
+ islong: false, // computed below
107
+ stage: calc.stage
108
+ }
109
+ }
110
+ break
111
+ }
112
+ }
113
+ if (breakLoop) break
114
+ }
115
+
116
+ // Compute derived fields — next block runs first so active block can overwrite secondstostatechange
117
+ if (calc.next) {
118
+ calc.next.duration = (calc.next.end - calc.next.start) / 1000
119
+ calc.next.islong = calc.next.duration >= 4 * 3600
120
+ calc.secondstostatechange = Math.floor((calc.next.start - now.getTime()) / 1000)
121
+ calc.next.isHigherStage = Number(calc.next.stage) > Number(calc.stage)
122
+ }
123
+
124
+ if (calc.active && calc.start !== undefined && calc.end !== undefined) {
125
+ calc.duration = (calc.end - calc.start) / 1000
126
+ calc.islong = calc.duration >= 4 * 3600
127
+ calc.secondstostatechange = Math.floor((calc.end - now.getTime()) / 1000)
128
+ }
129
+
130
+ return calc
131
+ }
@@ -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
+ }
@@ -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
+ }
@@ -1,371 +0,0 @@
1
- module.exports = function (RED) {
2
- 'use strict'
3
-
4
- const axios = require('axios')
5
- const EskomSePushInfo = {
6
- api: {
7
- lastUpdate: null,
8
- info: {}
9
- },
10
- status: {
11
- lastUpdate: null,
12
- info: {}
13
- },
14
- area: {
15
- lastUpdate: null,
16
- info: {}
17
- },
18
- calc: {}
19
- }
20
-
21
- function getMinutesToAPIReset () {
22
- const now = new Date()
23
- const targetTime = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 2, 0, 0)
24
- if (now > targetTime) {
25
- targetTime.setDate(targetTime.getDate() + 1)
26
- }
27
- const timeDiff = targetTime - now
28
- const minutesLeft = Math.floor(timeDiff / (1000 * 60))
29
-
30
- return minutesLeft
31
- }
32
-
33
- function checkAllowance (node) {
34
- const options = {}
35
- const headers = { token: node.config.licensekey }
36
-
37
- if (node.config.verbose === true) {
38
- node.warn('Running function checkAllowance')
39
- }
40
- axios.get('https://developer.sepush.co.za/business/2.0/api_allowance',
41
- { params: options, headers }).then(function (response) {
42
- EskomSePushInfo.api.info = response.data
43
- if (EskomSePushInfo.api.lastUpdate === null) {
44
- EskomSePushInfo.api.lastUpdate = new Date()
45
- updateSheddingStatus(node)
46
- }
47
- EskomSePushInfo.api.lastUpdate = new Date()
48
- })
49
- .catch(error => {
50
- node.warn({ error: error.message })
51
- })
52
- }
53
-
54
- function checkStage (node) {
55
- const options = {}
56
- const headers = { token: node.config.licensekey }
57
-
58
- if (node.config.verbose === true) {
59
- let warnstring = 'Running function checkStage'
60
- if (EskomSePushInfo.status.lastUpdate === null) {
61
- warnstring += ' - initial run'
62
- } else {
63
- warnstring += ' after ' + ((new Date() - EskomSePushInfo.status.lastUpdate) / 60000).toFixed(0) + ' minutes'
64
- }
65
- node.warn(warnstring)
66
- }
67
- axios.get('https://developer.sepush.co.za/business/2.0/status',
68
- { params: options, headers }).then(function (response) {
69
- EskomSePushInfo.status.info = response.data
70
- EskomSePushInfo.status.lastUpdate = new Date()
71
- // Call updateSheddingStatus again now we have new data
72
- updateSheddingStatus(node)
73
- })
74
- .catch(error => {
75
- node.warn({ error: error.message })
76
- })
77
- }
78
-
79
- function checkArea (node) {
80
- const options = { id: node.config.area }
81
- const headers = { token: node.config.licensekey }
82
- const url = 'https://developer.sepush.co.za/business/2.0/area'
83
-
84
- if (node.config.verbose === true) {
85
- let warnstring = 'Running function checkArea'
86
- if (EskomSePushInfo.area.lastUpdate === null) {
87
- warnstring += ' - initial run'
88
- } else {
89
- warnstring += ' after ' + ((new Date() - EskomSePushInfo.area.lastUpdate) / 60000).toFixed(0) + ' minutes'
90
- }
91
- node.warn(warnstring)
92
- }
93
- if (node.config.test) {
94
- options.test = 'current'
95
- }
96
- axios.get(url,
97
- { params: options, headers }).then(function (response) {
98
- EskomSePushInfo.area.info = response.data
99
- EskomSePushInfo.area.lastUpdate = new Date()
100
- // Call updateSheddingStatus again now we have new data
101
- updateSheddingStatus(node)
102
- })
103
- .catch(error => {
104
- node.warn({ error: error.message })
105
- })
106
- }
107
-
108
- function updateSheddingStatus (node, msg) {
109
- const now = new Date()
110
-
111
- // Check allowance every ten minutes
112
- if ((msg && msg.payload === 'allowance') || EskomSePushInfo.api.lastUpdate === null || (now.getTime() - EskomSePushInfo.api.lastUpdate.getTime()) > 600000) {
113
- checkAllowance(node)
114
- }
115
-
116
- // If we don't have API info, we just return
117
- if (Object.entries(EskomSePushInfo.api.info).length === 0) {
118
- node.warn('No API info (yet), refusing to continue')
119
- return
120
- }
121
-
122
- // The same is true if we have no API calls left
123
- if (EskomSePushInfo.api.info.allowance.count >= EskomSePushInfo.api.info.allowance.limit) {
124
- node.warn('No API calls left, not checking status/schedule')
125
- return
126
- }
127
-
128
- // Fetching actual information takes 2 calls, so calculate how long until the next API count
129
- // reset and divide the calls over the day. Wait at least 60 minutes between calls
130
- // reduce limit by api_allowance_buffer to cater for units consumed by other API calls
131
- if (node.config.verbose === true) {
132
- node.warn('Minutes to API Reset: ' + getMinutesToAPIReset())
133
- }
134
- const allowanceRemaining = EskomSePushInfo.api.info.allowance.limit - node.config.api_allowance_buffer - EskomSePushInfo.api.info.allowance.count
135
-
136
- if (allowanceRemaining > 0) {
137
- EskomSePushInfo.calc.sleeptime = Math.round(getMinutesToAPIReset() / Math.ceil(allowanceRemaining / 2))
138
- if (node.config.verbose === true) {
139
- node.warn('API allowance limit: ' + EskomSePushInfo.api.info.allowance.limit)
140
- node.warn('API allowance count: ' + EskomSePushInfo.api.info.allowance.count)
141
- node.warn('Calculated sleeptime: ' + EskomSePushInfo.calc.sleeptime)
142
- }
143
- if (EskomSePushInfo.calc.sleeptime < 10) {
144
- EskomSePushInfo.calc.sleeptime = 10
145
- if (node.config.verbose === true) {
146
- node.warn('Calculated sleeptime was less than 10. Set it to 10: ' + EskomSePushInfo.calc.sleeptime)
147
- }
148
- }
149
- } else {
150
- EskomSePushInfo.calc.sleeptime = 60
151
- if (node.config.verbose === true) {
152
- node.warn('Set sleeptime to 60 since allowance count is low: ' + EskomSePushInfo.calc.sleeptime)
153
- }
154
- }
155
-
156
- if ((msg && msg.payload === 'stage') || EskomSePushInfo.status.lastUpdate === null || (now.getTime() - EskomSePushInfo.status.lastUpdate) > (EskomSePushInfo.calc.sleeptime * 60000)) {
157
- checkStage(node)
158
- }
159
-
160
- if ((msg && msg.payload === 'area') || EskomSePushInfo.area.lastUpdate === null || (now.getTime() - EskomSePushInfo.area.lastUpdate) > (EskomSePushInfo.calc.sleeptime * 60000)) {
161
- checkArea(node)
162
- }
163
-
164
- // Now we have all info to continue. Just making sure that all update values are non null.
165
- if (EskomSePushInfo.api.lastUpdate === null ||
166
- EskomSePushInfo.status.lastUpdate === null ||
167
- EskomSePushInfo.area.lastUpdate === null) {
168
- (node.config.verbose === true) && node.warn('Not enough info to continue.')
169
- return
170
- }
171
-
172
- // Determine the current stage
173
- EskomSePushInfo.calc.stage = EskomSePushInfo.status.info.status[node.config.statusselect].stage
174
-
175
- if (node.config.verbose === true) {
176
- node.warn('API call status: ' + EskomSePushInfo.api.info.allowance.count + '/' + EskomSePushInfo.api.info.allowance.limit)
177
- node.warn(EskomSePushInfo)
178
- }
179
-
180
- // Default to false, overrule of loadshedding is active
181
- EskomSePushInfo.calc.active = false
182
-
183
- // Are there any events going on?
184
- if (Object.entries(EskomSePushInfo.area.info.events).length > 0) {
185
- const EventStart = Date.parse(EskomSePushInfo.area.info.events[0].start)
186
- const EventEnd = Date.parse(EskomSePushInfo.area.info.events[0].end)
187
- if (now >= EventStart && now < EventEnd) {
188
- EskomSePushInfo.calc.type = 'event'
189
- EskomSePushInfo.calc.active = true
190
- if (EskomSePushInfo.area.info.events[0].note.match(/Stage (\d+)/i)) {
191
- EskomSePushInfo.calc.stage = EskomSePushInfo.area.info.events[0].note.match(/Stage (\d+)/i)[1]
192
- }
193
- EskomSePushInfo.calc.start = EventStart
194
- EskomSePushInfo.calc.end = EventEnd
195
- } else {
196
- EskomSePushInfo.calc.next = {
197
- type: 'event',
198
- start: EventStart,
199
- end: EventEnd,
200
- stage: EskomSePushInfo.area.info.events[0].note.match(/Stage (\d+)/i)[1]
201
- }
202
- }
203
- }
204
-
205
- // Scheduled downtime has the thing that the time is in locatime
206
- // So not just like events, where they are in UTC with an offset
207
- let BreakLoop = false
208
- for (const dates of EskomSePushInfo.area.info.schedule.days) {
209
- const stageIndex = EskomSePushInfo.calc.stage - 1
210
- if (stageIndex >= 0 && stageIndex < dates.stages.length) {
211
- if (Array.isArray(dates.stages[stageIndex])) {
212
- for (const schedule of dates.stages[stageIndex]) {
213
- const ScheduleStart = Date.parse(dates.date + ' ' + schedule.split('-')[0])
214
- let ScheduleEnd = Date.parse(dates.date + ' ' + schedule.split('-')[1])
215
- if (ScheduleEnd < ScheduleStart) {
216
- ScheduleEnd += (24 * 60 * 60 * 1000)
217
- }
218
- if (now < ScheduleEnd) {
219
- BreakLoop = true
220
- // This schedule is either active or will be next
221
- if (now >= ScheduleStart) {
222
- EskomSePushInfo.calc.active = true
223
- EskomSePushInfo.calc.type = 'schedule'
224
- EskomSePushInfo.calc.start = ScheduleStart
225
- EskomSePushInfo.calc.end = ScheduleEnd
226
- } else {
227
- EskomSePushInfo.calc.next = {
228
- type: 'schedule',
229
- start: ScheduleStart,
230
- end: ScheduleEnd,
231
- stage: EskomSePushInfo.calc.stage
232
- }
233
- }
234
- }
235
- if (BreakLoop) { break }
236
- }
237
- } else {
238
- console.warn('Not an array:', dates.stages[stageIndex]) // Warning if not an array
239
- }
240
- } else {
241
- console.warn(`Invalid stage index: ${stageIndex}. No loadshedding going on?`) // Warning if stage index is out of bounds
242
- BreakLoop = true
243
- }
244
- if (BreakLoop) { break }
245
- }
246
-
247
- if (EskomSePushInfo.calc.next) {
248
- EskomSePushInfo.calc.next.duration = (EskomSePushInfo.calc.next.end - EskomSePushInfo.calc.next.start) / 1000
249
- EskomSePushInfo.calc.next.islong = EskomSePushInfo.calc.next.duration >= (4 * 3600)
250
- EskomSePushInfo.calc.secondstostatechange = parseInt((EskomSePushInfo.calc.next.start - now) / 1000)
251
- EskomSePushInfo.calc.next.isHigherStage = EskomSePushInfo.calc.next.stage > EskomSePushInfo.calc.stage
252
- }
253
-
254
- if (EskomSePushInfo.calc.active) {
255
- EskomSePushInfo.calc.duration = (EskomSePushInfo.calc.end - EskomSePushInfo.calc.start) / 1000
256
- EskomSePushInfo.calc.islong = EskomSePushInfo.calc.duration >= (4 * 3600)
257
- EskomSePushInfo.calc.secondstostatechange = parseInt((EskomSePushInfo.calc.end - now) / 1000)
258
- }
259
-
260
- if (node.config.verbose === true) {
261
- node.warn(EskomSePushInfo.calc)
262
- }
263
-
264
- // Send output
265
- node.send([{
266
- payload: EskomSePushInfo.calc.active,
267
- stage: EskomSePushInfo.calc.stage,
268
- statusselect: node.config.statusselect,
269
- api: {
270
- count: EskomSePushInfo.api.info.allowance.count,
271
- limit: EskomSePushInfo.api.info.allowance.limit
272
- },
273
- calc: EskomSePushInfo.calc
274
- }, {
275
- stage: EskomSePushInfo.status,
276
- schedule: EskomSePushInfo.area
277
- }])
278
-
279
- // And update the status
280
- let fill = 'green'
281
- let shape = 'ring'
282
- let statusText = 'Stage ' + EskomSePushInfo.calc.stage + ': '
283
-
284
- if (EskomSePushInfo.calc.active) {
285
- fill = 'yellow'
286
- if (EskomSePushInfo.calc.type === 'event') {
287
- shape = 'dot'
288
- }
289
- if (EskomSePushInfo.calc.start) {
290
- statusText += new Date(EskomSePushInfo.calc.start).toLocaleTimeString([], { timeStyle: 'short' })
291
- statusText += ' - ' + new Date(EskomSePushInfo.calc.end).toLocaleTimeString([], { timeStyle: 'short' })
292
- }
293
- } else {
294
- if (EskomSePushInfo.calc.next) {
295
- if (new Date(EskomSePushInfo.calc.next.start).getUTCDay() !== now.getUTCDate()) {
296
- statusText += new Date(EskomSePushInfo.calc.next.start).toLocaleString([], { weekday: 'short' }) + ' '
297
- }
298
- statusText += new Date(EskomSePushInfo.calc.next.start).toLocaleTimeString([], { timeStyle: 'short' })
299
- statusText += ' - ' + new Date(EskomSePushInfo.calc.next.end).toLocaleTimeString([], { timeStyle: 'short' })
300
- }
301
- }
302
-
303
- statusText += ' (API: ' + EskomSePushInfo.api.info.allowance.count + '/' + EskomSePushInfo.api.info.allowance.limit + ')'
304
- node.status({
305
- fill, shape, text: statusText
306
- })
307
- }
308
-
309
- function EskomSePush (config) {
310
- RED.nodes.createNode(this, config)
311
-
312
- const node = this
313
- node.config = config
314
-
315
- updateSheddingStatus(node)
316
- const intervalId = setInterval(function () {
317
- updateSheddingStatus(node)
318
- }, 60000)
319
-
320
- node.on('input', function (msg) {
321
- updateSheddingStatus(node, msg)
322
- })
323
-
324
- node.on('close', function () {
325
- clearInterval(intervalId)
326
- })
327
- }
328
-
329
- RED.nodes.registerType('eskomsepush', EskomSePush)
330
-
331
- RED.httpNode.get('/eskomsepush/search', (req, res) => {
332
- if (!req.query || !req.query.token || !req.query.search) {
333
- res.setHeader('Content-Type', 'application/json')
334
- return res.send('invalid')
335
- }
336
- const headers = {
337
- token: req.query.token
338
- }
339
- const options = {
340
- text: req.query.search
341
- }
342
-
343
- res.setHeader('Content-Type', 'application/json')
344
- axios.get('https://developer.sepush.co.za/business/2.0/areas_search',
345
- { params: options, headers }).then(function (response) {
346
- return res.send(response.data)
347
- })
348
- .catch(error => {
349
- return res.send({ error: error.message })
350
- })
351
- })
352
- RED.httpNode.get('/eskomsepush/api', (req, res) => {
353
- if (!req.query.token) {
354
- res.setHeader('Content-Type', 'application/json')
355
- return res.send('invalid')
356
- }
357
- const headers = {
358
- token: req.query.token
359
- }
360
- const options = {}
361
-
362
- res.setHeader('Content-Type', 'application/json')
363
- axios.get('https://developer.sepush.co.za/business/2.0/api_allowance',
364
- { params: options, headers }).then(function (response) {
365
- return res.send(response.data)
366
- })
367
- .catch(error => {
368
- return res.send({ error: error.message })
369
- })
370
- })
371
- }