node-red-contrib-eskomsepush 0.0.18 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +8 -0
- package/README.md +5 -3
- package/jest.config.js +9 -0
- package/package.json +14 -7
- package/src/__tests__/api.test.ts +103 -0
- package/src/__tests__/loadshedding.test.ts +262 -0
- package/src/lib/api.ts +45 -0
- package/src/lib/loadshedding.ts +131 -0
- package/src/lib/types.ts +109 -0
- package/src/nodes/eskomsepush.html +9 -8
- package/src/nodes/eskomsepush.ts +203 -0
- package/tsconfig.json +17 -0
- package/tsconfig.test.json +8 -0
- package/src/nodes/eskomsepush.js +0 -371
package/README.md
CHANGED
|
@@ -37,10 +37,10 @@ If you don't want to use API quota by searching, and you already know the id of
|
|
|
37
37
|
|
|
38
38
|
To fetch the area id manually, make an `areas_search` API call using your API license key `token`, a word of search `text`. In the response returned by the API, copy the `id` value of the matching area.
|
|
39
39
|
|
|
40
|
-
In the example below (on MacOS), `curl` is used to query the API and the search text value is 'ballito' (the license key token is invalid and must be replaced with a valid key).
|
|
40
|
+
In the example below (on MacOS), `curl` is used to query the API and the search text value is 'ballito' (the license key token is invalid and must be replaced with a valid key).
|
|
41
41
|
|
|
42
42
|
```
|
|
43
|
-
% curl --location --request GET 'https://developer.sepush.co.za/business/
|
|
43
|
+
% curl --location --request GET 'https://developer.sepush.co.za/business/3.0/areas_search?text=ballito' --header 'token: 2DFB82AC-46254F6E-A68B26A4-8DF1303E'
|
|
44
44
|
{
|
|
45
45
|
"areas":[
|
|
46
46
|
{"id":"eskmo-15-ballitokwadukuzakwazulunatal","name":"Ballito (15)","region":"Eskom Municipal, Kwadukuza, Kwazulu-Natal"},
|
|
@@ -49,6 +49,8 @@ In the example below (on MacOS), `curl` is used to query the API and the search
|
|
|
49
49
|
}
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
> **Note:** The API returns v2-style IDs with a location suffix. When entering the area id in the node, use only the first two dash-separated parts — e.g. use `eskmo-15`, not `eskmo-15-ballitokwadukuzakwazulunatal`. The node will warn you if it detects an old-style ID.
|
|
53
|
+
|
|
52
54
|
Then you need to fill out which status to follow. This can be either _National_ (Eskom) or _Cape Town_.
|
|
53
55
|
|
|
54
56
|
If the _test_ checkbox has been selected, test data for the specified area will be fetched instead of the actual schedule. This is useful when debugging.
|
|
@@ -114,7 +116,7 @@ a matching _schedule_.
|
|
|
114
116
|
|
|
115
117
|
### Documentation
|
|
116
118
|
|
|
117
|
-
Documentation for the API can be found [here](https://
|
|
119
|
+
Documentation for the API can be found [here](https://developer.sepush.co.za/business/3.0/)
|
|
118
120
|
|
|
119
121
|
When quota has been exceeded:
|
|
120
122
|
|
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.
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
}
|