node-red-contrib-eskomsepush 0.0.17 → 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.
- package/.claude/settings.local.json +8 -0
- 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.ts +203 -0
- package/tsconfig.json +17 -0
- package/tsconfig.test.json +8 -0
- package/src/nodes/eskomsepush.js +0 -368
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
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Node-RED interface for the Eskomsepush API",
|
|
5
|
-
"main": "index.js",
|
|
6
5
|
"scripts": {
|
|
7
|
-
"
|
|
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": "./
|
|
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.
|
|
34
|
+
"axios": "^1.17.0"
|
|
32
35
|
},
|
|
33
36
|
"devDependencies": {
|
|
34
|
-
"
|
|
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": ">=
|
|
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
|
+
}
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/nodes/eskomsepush.js
DELETED
|
@@ -1,368 +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) // Warning if stage index is out of bounds
|
|
242
|
-
}
|
|
243
|
-
if (BreakLoop) { break }
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (EskomSePushInfo.calc.next) {
|
|
247
|
-
EskomSePushInfo.calc.next.duration = (EskomSePushInfo.calc.next.end - EskomSePushInfo.calc.next.start) / 1000
|
|
248
|
-
EskomSePushInfo.calc.next.islong = EskomSePushInfo.calc.next.duration >= (4 * 3600)
|
|
249
|
-
EskomSePushInfo.calc.secondstostatechange = parseInt((EskomSePushInfo.calc.next.start - now) / 1000)
|
|
250
|
-
EskomSePushInfo.calc.next.isHigherStage = EskomSePushInfo.calc.next.stage > EskomSePushInfo.calc.stage
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (EskomSePushInfo.calc.active) {
|
|
254
|
-
EskomSePushInfo.calc.duration = (EskomSePushInfo.calc.end - EskomSePushInfo.calc.start) / 1000
|
|
255
|
-
EskomSePushInfo.calc.islong = EskomSePushInfo.calc.duration >= (4 * 3600)
|
|
256
|
-
EskomSePushInfo.calc.secondstostatechange = parseInt((EskomSePushInfo.calc.end - now) / 1000)
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (node.config.verbose === true) {
|
|
260
|
-
node.warn(EskomSePushInfo.calc)
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Send output
|
|
264
|
-
node.send([{
|
|
265
|
-
payload: EskomSePushInfo.calc.active,
|
|
266
|
-
stage: EskomSePushInfo.calc.stage,
|
|
267
|
-
statusselect: node.config.statusselect,
|
|
268
|
-
api: {
|
|
269
|
-
count: EskomSePushInfo.api.info.allowance.count,
|
|
270
|
-
limit: EskomSePushInfo.api.info.allowance.limit
|
|
271
|
-
},
|
|
272
|
-
calc: EskomSePushInfo.calc
|
|
273
|
-
}, {
|
|
274
|
-
stage: EskomSePushInfo.status,
|
|
275
|
-
schedule: EskomSePushInfo.area
|
|
276
|
-
}])
|
|
277
|
-
|
|
278
|
-
// And update the status
|
|
279
|
-
let fill = 'green'
|
|
280
|
-
let shape = 'ring'
|
|
281
|
-
let statusText = 'Stage ' + EskomSePushInfo.calc.stage + ': '
|
|
282
|
-
|
|
283
|
-
if (EskomSePushInfo.calc.active) {
|
|
284
|
-
fill = 'yellow'
|
|
285
|
-
if (EskomSePushInfo.calc.type === 'event') {
|
|
286
|
-
shape = 'dot'
|
|
287
|
-
}
|
|
288
|
-
if (EskomSePushInfo.calc.start) {
|
|
289
|
-
statusText += new Date(EskomSePushInfo.calc.start).toLocaleTimeString([], { timeStyle: 'short' })
|
|
290
|
-
statusText += ' - ' + new Date(EskomSePushInfo.calc.end).toLocaleTimeString([], { timeStyle: 'short' })
|
|
291
|
-
}
|
|
292
|
-
} else {
|
|
293
|
-
if (new Date(EskomSePushInfo.calc.next.start).getUTCDay() !== now.getUTCDate()) {
|
|
294
|
-
statusText += new Date(EskomSePushInfo.calc.next.start).toLocaleString([], { weekday: 'short' }) + ' '
|
|
295
|
-
}
|
|
296
|
-
statusText += new Date(EskomSePushInfo.calc.next.start).toLocaleTimeString([], { timeStyle: 'short' })
|
|
297
|
-
statusText += ' - ' + new Date(EskomSePushInfo.calc.next.end).toLocaleTimeString([], { timeStyle: 'short' })
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
statusText += ' (API: ' + EskomSePushInfo.api.info.allowance.count + '/' + EskomSePushInfo.api.info.allowance.limit + ')'
|
|
301
|
-
node.status({
|
|
302
|
-
fill, shape, text: statusText
|
|
303
|
-
})
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function EskomSePush (config) {
|
|
307
|
-
RED.nodes.createNode(this, config)
|
|
308
|
-
|
|
309
|
-
const node = this
|
|
310
|
-
node.config = config
|
|
311
|
-
|
|
312
|
-
updateSheddingStatus(node)
|
|
313
|
-
const intervalId = setInterval(function () {
|
|
314
|
-
updateSheddingStatus(node)
|
|
315
|
-
}, 60000)
|
|
316
|
-
|
|
317
|
-
node.on('input', function (msg) {
|
|
318
|
-
updateSheddingStatus(node, msg)
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
node.on('close', function () {
|
|
322
|
-
clearInterval(intervalId)
|
|
323
|
-
})
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
RED.nodes.registerType('eskomsepush', EskomSePush)
|
|
327
|
-
|
|
328
|
-
RED.httpNode.get('/eskomsepush/search', (req, res) => {
|
|
329
|
-
if (!req.query || !req.query.token || !req.query.search) {
|
|
330
|
-
res.setHeader('Content-Type', 'application/json')
|
|
331
|
-
return res.send('invalid')
|
|
332
|
-
}
|
|
333
|
-
const headers = {
|
|
334
|
-
token: req.query.token
|
|
335
|
-
}
|
|
336
|
-
const options = {
|
|
337
|
-
text: req.query.search
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
res.setHeader('Content-Type', 'application/json')
|
|
341
|
-
axios.get('https://developer.sepush.co.za/business/2.0/areas_search',
|
|
342
|
-
{ params: options, headers }).then(function (response) {
|
|
343
|
-
return res.send(response.data)
|
|
344
|
-
})
|
|
345
|
-
.catch(error => {
|
|
346
|
-
return res.send({ error: error.message })
|
|
347
|
-
})
|
|
348
|
-
})
|
|
349
|
-
RED.httpNode.get('/eskomsepush/api', (req, res) => {
|
|
350
|
-
if (!req.query.token) {
|
|
351
|
-
res.setHeader('Content-Type', 'application/json')
|
|
352
|
-
return res.send('invalid')
|
|
353
|
-
}
|
|
354
|
-
const headers = {
|
|
355
|
-
token: req.query.token
|
|
356
|
-
}
|
|
357
|
-
const options = {}
|
|
358
|
-
|
|
359
|
-
res.setHeader('Content-Type', 'application/json')
|
|
360
|
-
axios.get('https://developer.sepush.co.za/business/2.0/api_allowance',
|
|
361
|
-
{ params: options, headers }).then(function (response) {
|
|
362
|
-
return res.send(response.data)
|
|
363
|
-
})
|
|
364
|
-
.catch(error => {
|
|
365
|
-
return res.send({ error: error.message })
|
|
366
|
-
})
|
|
367
|
-
})
|
|
368
|
-
}
|