| Key | Value(s) | Default Value | Value type | Description |
| -------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| $schemaVersion | 2 | 2 | integer | Configuration schema version. Set to 2. Files without this field are migrated from v0; deprecated keys are remapped on every load. |
-| supervisionUrls | | [] | string \| string[] | string or strings array containing global connection URIs to OCPP-J servers (`ws://`, `wss://`, `http://`, `https://` or `ws+unix://`) |
+| supervisionUrls | | [] | string \| string[] | string or strings array containing global connection URIs to OCPP-J servers (`ws://`, `wss://`, `http://`, `https://` or `ws+unix://`; no `#` fragment or `?` query, since the charging station identity is appended to the URL; a `ws+unix://` URL separates the socket path from the request path with one `:`, e.g. `ws+unix:///path/to/socket.sock:/OCPP16`) |
| supervisionUrlDistribution | round-robin/random/charging-station-affinity | charging-station-affinity | string | supervision urls distribution policy to simulated charging stations |
| log | | {<br />"enabled": true,<br />"file": "logs/combined.log",<br />"errorFile": "logs/error.log",<br />"statisticsInterval": 60,<br />"level": "info",<br />"console": false,<br />"format": "simple",<br />"rotate": true<br />} | {<br />enabled?: boolean;<br />file?: string;<br />errorFile?: string;<br />statisticsInterval?: number;<br />level?: string;<br />console?: boolean;<br />format?: string;<br />rotate?: boolean;<br />maxFiles?: string \| number;<br />maxSize?: string \| number;<br />} | Log configuration section:<br />- _enabled_: enable logging<br />- _file_: log file relative path<br />- _errorFile_: error log file relative path<br />- _statisticsInterval_: seconds between charging stations statistics output in the logs<br />- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level</br >- _console_: output logs on the console<br />- _format_: [winston](https://github.com/winstonjs/winston) log format<br />- _rotate_: enable daily log files rotation<br />- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options<br />- _maxSize_: maximum size of log files in bytes, or units of kb, mb, and gb: https://github.com/winstonjs/winston-daily-rotate-file#options |
| worker | | {<br />"processType": "workerSet",<br />"startDelay": 500,<br />"elementAddDelay": 0,<br />"elementsPerWorker": 'auto',<br />"poolMinSize": 4,<br />"poolMaxSize": 16<br />} | {<br />processType?: WorkerProcessType;<br />startDelay?: number;<br />elementAddDelay?: number;<br />elementsPerWorker?: number \| 'auto' \| 'all';<br />poolMinSize?: number;<br />poolMaxSize?: number;<br />resourceLimits?: ResourceLimits;<br />} | Worker configuration section:<br />- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)<br />- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)<br />- _elementAddDelay_: milliseconds to wait between charging station add<br />- _elementsPerWorker_: number of charging stations per worker threads for the `workerSet` process type (`auto` means (number of stations) / (number of CPUs) \* 1.5 if (number of stations) > (number of CPUs), otherwise 1; `all` means a unique worker will run all charging stations)<br />- _poolMinSize_: worker threads pool minimum number of threads</br >- _poolMaxSize_: worker threads pool maximum number of threads<br />- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option |
.strict()
/**
- * Supervision URL — a URL that the `ws` WebSocket client can dial: scheme
- * `ws:`, `wss:`, `http:`, `https:` or `ws+unix:`, no fragment identifier, and
- * a socket path for `ws+unix:`. A scheme-less `host:port/path` string parses
- * as a URL with the host as its scheme, so syntax validation alone would
- * accept a value that fails at connection time.
+ * Supervision URL — a URL that the `ws` WebSocket client can dial once the
+ * charging station identity is appended to it: scheme `ws:`, `wss:`, `http:`,
+ * `https:` or `ws+unix:`; no fragment delimiter (`#`, even with an empty
+ * fragment) and no query delimiter (`?`), since the appended identity would
+ * end up in the fragment or the query; and for `ws+unix:` a socket path other
+ * than `/`, then exactly one `:`, then an empty request path or one starting
+ * with `/` (`ws` splits the pathname at `:`, so without the separator the
+ * identity is appended to the socket path, and a request path without a
+ * leading `/` is not a valid HTTP request target). A
+ * scheme-less `host:port/path` string parses as a URL with the host as its
+ * scheme, so syntax validation alone would accept a value that fails at
+ * connection time.
* Shared by the configuration file and the UI request payload gate so a value
* is accepted by both or neither.
*/
error: 'must be a WebSocket URL (ws://, wss://, http://, https:// or ws+unix://)',
protocol: /^(?:wss?|https?|ws\+unix)$/,
})
- .refine(value => !URL.canParse(value) || new URL(value).hash === '', {
+ .refine(value => !URL.canParse(value) || !new URL(value).href.includes('#'), {
message: 'must not contain a fragment identifier (#...)',
})
+ .refine(value => !URL.canParse(value) || !new URL(value).href.includes('?'), {
+ message: 'must not contain a query string (?...)',
+ })
.refine(
value => {
if (!URL.canParse(value)) {
return true
}
const url = new URL(value)
- return url.protocol !== 'ws+unix:' || url.pathname !== ''
+ if (url.protocol !== 'ws+unix:') {
+ return true
+ }
+ const [socketPath, ...requestPath] = url.pathname.split(':')
+ return (
+ requestPath.length === 1 &&
+ socketPath !== '' &&
+ socketPath !== '/' &&
+ (requestPath[0] === '' || requestPath[0].startsWith('/'))
+ )
},
{
- message: 'ws+unix:// URLs must include the socket path',
+ message:
+ "ws+unix:// URLs must include the socket path, then exactly one ':' before a request path starting with '/' (ws+unix:///path/to/socket.sock:/path)",
}
)
'wss://localhost:8010/OCPP16',
'http://localhost:8010/OCPP16',
'https://localhost:8010/OCPP16',
- 'ws+unix:///tmp/simulator.sock',
'ws+unix:///tmp/simulator.sock:/OCPP16',
+ 'ws+unix:///tmp/simulator.sock:/',
+ 'ws+unix:///tmp/simulator.sock:',
+ 'ws+unix:s:/OCPP16',
+ 'ws+unix:./s:/OCPP16',
'ws:///OCPP16',
]
const rejectedUrls = [
}
})
- await it('should reject a URL with a fragment identifier', () => {
- const result = SupervisionUrlSchema.safeParse('ws://localhost:8010/OCPP16#fragment')
- if (result.success) {
- assert.fail('Expected a URL with a fragment identifier to be rejected')
+ await it('should reject a URL with a fragment delimiter, even with an empty fragment', () => {
+ for (const url of ['ws://localhost:8010/OCPP16#fragment', 'ws://localhost:8010/OCPP16#']) {
+ const result = SupervisionUrlSchema.safeParse(url)
+ if (result.success) {
+ assert.fail(`Expected '${url}' to be rejected`)
+ }
+ assert.match(result.error.issues[0].message, /fragment identifier/)
+ }
+ })
+
+ await it('should reject a URL with a query string delimiter, even with an empty query', () => {
+ for (const url of [
+ 'ws://localhost:8010/OCPP16?token=secret',
+ 'ws://localhost:8010/OCPP16?',
+ ]) {
+ const result = SupervisionUrlSchema.safeParse(url)
+ if (result.success) {
+ assert.fail(`Expected '${url}' to be rejected`)
+ }
+ assert.match(result.error.issues[0].message, /query string/)
}
- assert.match(result.error.issues[0].message, /fragment identifier/)
})
- await it('should reject a ws+unix URL without a socket path', () => {
- for (const url of ['ws+unix://', 'ws+unix:']) {
+ await it("should reject a ws+unix URL without a socket path, exactly one ':' and a request path starting with '/'", () => {
+ for (const url of [
+ 'ws+unix://',
+ 'ws+unix:',
+ 'ws+unix:///tmp/simulator.sock',
+ 'ws+unix:///:/OCPP16',
+ 'ws+unix:///tmp/simulator.sock:/OC:PP16',
+ 'ws+unix:///tmp/simulator.sock:OCPP16',
+ 'ws+unix:s:OCPP16',
+ ]) {
const result = SupervisionUrlSchema.safeParse(url)
if (result.success) {
assert.fail(`Expected '${url}' to be rejected`)
}
})
- await it('should match the URLs accepted by the ws client', () => {
+ await it('should match the connection URLs the ws client can dial', () => {
const edgeCaseUrls = [
'ws://localhost:8010/OCPP16#fragment',
'wss://localhost:8010/OCPP16#',
'ws+unix://',
'ws+unix:',
+ 'ws+unix:///tmp/simulator.sock',
+ ]
+ // URLs the ws client accepts but that do not reach the intended
+ // endpoint once the identity is appended (checked against real servers).
+ const wrongEndpointUrls = [
+ 'ws://localhost:8010/OCPP16?token=secret',
+ 'ws://localhost:8010/OCPP16?',
+ 'ws+unix:///:/OCPP16',
+ 'ws+unix:///tmp/simulator.sock:/OC:PP16',
+ 'ws+unix:s:OCPP16',
]
+ for (const url of wrongEndpointUrls) {
+ assert.strictEqual(SupervisionUrlSchema.safeParse(url).success, false, url)
+ }
for (const url of [
...acceptedUrls,
...rejectedUrls.filter(url => URL.canParse(url)),
...edgeCaseUrls,
]) {
- let wsAccepts = true
+ // The charging station dials the supervision URL with its identity
+ // appended (see ChargingStation.wsConnectionUrl).
+ const connectionUrl = `${url}${url.endsWith('/') ? '' : '/'}CS-1`
+ let wsCanDial = true
try {
- const webSocket = new WebSocket(url)
+ const webSocket = new WebSocket(connectionUrl)
webSocket.on('error', () => undefined)
webSocket.terminate()
+ // ws splits a ws+unix pathname at ':' into socket path and request
+ // path; without ':' the identity ends up in the socket path.
+ const { pathname, protocol } = new URL(connectionUrl)
+ if (protocol === 'ws+unix:' && pathname.indexOf(':') <= 0) {
+ wsCanDial = false
+ }
} catch {
- wsAccepts = false
+ wsCanDial = false
}
- assert.strictEqual(SupervisionUrlSchema.safeParse(url).success, wsAccepts, url)
+ assert.strictEqual(SupervisionUrlSchema.safeParse(url).success, wsCanDial, url)
}
})
})