Skip to content

Commit f295d03

Browse files
committed
Merge branch 'bindphrase' into unified-lua-lsp
2 parents 2a6de92 + e7b1680 commit f295d03

23 files changed

Lines changed: 1991 additions & 196 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
*.luac
22
src/WIDGETS/ELRSVTXAdmin/presets.txt
3+
src/SCRIPTS/TOOLS/ExpressLRSBind/history.txt
34
bin/
45
.claude/plans/
56
TODO.md

README.md

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ExpressLRS Lua Scripts
22

3-
Lua configuration tool for ExpressLRS on EdgeTX radios. Works on both black & white LCD and color LCD radios.
3+
Lua configuration tool and bind phrase manager for ExpressLRS on EdgeTX radios. Both work on black & white LCD and color LCD radios.
44

55
The package also includes two color-LCD widgets: the **ELRS Telemetry Widget** and the **VTX Administrator Widget**.
66

@@ -22,6 +22,18 @@ SCRIPTS/
2222
crsf_params.lua -- parameter codec (tool, VTX Admin)
2323
crsf_session.lua -- stateful parameter client (tool, VTX Admin)
2424
crsf_elrsinfo.lua -- TX module info state (telemetry widget)
25+
msp.lua -- MSP-over-CRSF codec (bind tool)
26+
defer.lua -- deferred-callback timer (bind tool)
27+
loader.lua -- GC-guarded script loader (both tools)
28+
edgetx_version.lua -- EdgeTX minimum-version gate (both tools)
29+
ui/
30+
lcd/
31+
text_edit.lua -- BW text editor (bind tool)
32+
alert.lua -- BW alert screen (both tools)
33+
lvgl/
34+
dialogs.lua -- color startup dialogs (both tools)
35+
sensors.lua -- telemetry sensor reader
36+
file_storage.lua -- key=value file persistence
2537
shim.lua -- BW compatibility shim
2638
TOOLS/
2739
ExpressLRS/
@@ -30,6 +42,12 @@ SCRIPTS/
3042
ui/
3143
lvgl.lua -- color LCD UI (LVGL)
3244
lcd.lua -- black & white LCD UI
45+
ExpressLRSBind/
46+
main.lua -- entry point
47+
history_storage.lua -- bind phrase history persistence
48+
ui/
49+
lvgl.lua -- color LCD UI (LVGL)
50+
lcd.lua -- black & white LCD UI
3351
WIDGETS/
3452
ELRSTelemetry/
3553
main.lua
@@ -44,7 +62,7 @@ WIDGETS/
4462
...
4563
```
4664

47-
The shared library `SCRIPTS/ELRS/` is required by the configuration tool and both widgets.
65+
The shared library `SCRIPTS/ELRS/` is required by both tools and both widgets.
4866

4967
### Install with edgetx-cli
5068

@@ -64,6 +82,27 @@ The main tool (`SCRIPTS/TOOLS/ExpressLRS/`) lets you configure your ExpressLRS t
6482

6583
<img src="screenshots/tool_main.png" width="472" alt="ExpressLRS Configuration Tool">
6684

85+
## ExpressLRS Bind Phrase Manager
86+
87+
The bind tool (`SCRIPTS/TOOLS/ExpressLRSBind/`) sets the bind phrase -- or a raw UID entered as
88+
comma-separated bytes -- on the transmitter, the receiver, or both in one sequence, and can put the
89+
TX in bind mode or unbind a connected receiver. The last five phrases are kept as a pick-and-send
90+
history.
91+
92+
The transmitter's UID is read back automatically -- when the tool opens, after every write, and
93+
whenever you change the target -- so the page always shows the identity the handset is transmitting
94+
on. The receiver's own UID is not read: a receiver only answers over an active link, and a link only
95+
exists between devices that already share a UID, so its answer could never differ from the
96+
transmitter's. What is worth knowing is whether it is there at all, which the link reports for free.
97+
98+
Setting the phrase over MSP requires **ExpressLRS 4.1+** on the device; a pre-4.1 module never
99+
answers, and the tool says so in the transmitter row rather than retrying forever. Bind and unbind
100+
are plain CRSF commands and work on every ExpressLRS version.
101+
102+
<img src="screenshots/tool_bind_bw.png" width="256" alt="ExpressLRS Bind Phrase Manager"><br/>
103+
104+
<img src="screenshots/tool_bind.png" width="472" alt="ExpressLRS Bind Phrase Manager">
105+
67106
## Widgets
68107

69108
Both widgets running side-by-side on the home screen:
@@ -92,3 +131,6 @@ See [docs/development.md](docs/development.md) for the tool's internal architect
92131
|------------|----------|------------|
93132
| Black & white LCD | EdgeTX 2.11.6+, 2.12.1+, or 3.0+ | v3.5.4+ |
94133
| Color LCD | EdgeTX 2.11.6+, 2.12.1+, or 3.0+ | v3.5.4+ |
134+
135+
The bind phrase manager additionally requires **ExpressLRS 4.1+** on the device for its MSP
136+
configuration support; on older firmware it reports "No response (needs ELRS 4.1+)".

docs/development.md

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Development
22

3-
This document covers the internal architecture of the ExpressLRS configuration tool and the CRSF simulator used for testing inside the EdgeTX simulator without real hardware.
3+
This document covers the internal architecture of the ExpressLRS tools and the CRSF simulator used for testing inside the EdgeTX simulator without real hardware.
44

55
## Make targets
66

@@ -32,30 +32,61 @@ Run `make help` to list all targets. The Makefile groups them into three categor
3232

3333
## Architecture
3434

35+
The configuration tool, `SCRIPTS/TOOLS/ExpressLRS/`:
36+
3537
| Module | Purpose |
3638
|--------|---------|
3739
| `main.lua` | Entry point, run-loop orchestrator, and the App policy layer (device switching, folder-ready edges, the synthetic "Other Devices" row types) over a `crsf_session.lua` instance |
3840
| `navigation.lua` | Folder and device navigation stack |
3941
| `ui/lvgl.lua` | Color LCD interface (LVGL dialogs, command pages, warnings) |
4042
| `ui/lcd.lua` | BW LCD interface (text cursor, popups) |
4143

42-
The tool builds on the shared `SCRIPTS/ELRS/` library, which the widgets use too:
44+
The bind phrase manager, `SCRIPTS/TOOLS/ExpressLRSBind/` (sets the bind phrase / UID over MSP;
45+
the device side requires ExpressLRS 4.1+, and a pre-4.1 device simply never answers -- the tool's
46+
bounded UID retry reports that instead of polling forever).
47+
48+
Only the transmitter's UID is read, and only on the events that can change it: the tool opening, a
49+
write completing, and the target selector moving. The receiver is never asked. It answers over the
50+
link, a link only exists between devices already sharing a UID, so its answer is the transmitter's
51+
number by construction; the link's presence carries the whole of what asking would have told us, and
52+
costs no over-air traffic. That is also why there is no manual refresh -- every event a "read UID"
53+
button existed to cover is one the App observes for itself:
54+
55+
| Module | Purpose |
56+
|--------|---------|
57+
| `main.lua` | Entry point and the App layer: target selection (TX/RX/Both), phrase-vs-raw-UID parsing, the two-step Both sequence (RX first -- writing its phrase drops it off the link -- then TX), the bounded transmitter UID probe, and the frame router over `crsf.drain` |
58+
| `history_storage.lua` | The last five phrases, newest first, persisted through `file_storage.lua` as indexed keys `h1`..`h5` |
59+
| `ui/lvgl.lua` | Color LCD interface |
60+
| `ui/lcd.lua` | BW LCD interface (line list, phrase editing through `ui/lcd/text_edit.lua`). Note `popupConfirmation`'s message argument never reaches the screen on BW: EdgeTX's Lua binding sets `warningInfoText` without `warningInfoLength` (`api_general.cpp` `luaPopupConfirmation`), so a confirmation has only its 24-char title (`WARNING_LINE_LEN`) to say what it needs |
61+
62+
Both tools pick their UI chunk at runtime -- `local useLvgl = (lvgl ~= nil)` -- and load exactly one
63+
of `ui/lvgl.lua` or `ui/lcd.lua`; there are no per-radio builds.
64+
65+
The tools build on the shared `SCRIPTS/ELRS/` library, which the widgets use too:
4366

4467
| Module | Purpose |
4568
|--------|---------|
4669
| `SCRIPTS/ELRS/crsf.lua` | CRSF constants, telemetry transport (`pop`/`drain`/`push`), module detection, derived link state (`hasTelemetry`, refreshed as a drain empties the queue), stateless frame decoders (`decodeDeviceInfo`, `decodeElrsStatus`, `isElrsV1Frame`) |
4770
| `SCRIPTS/ELRS/crsf_params.lua` | Opt-in parameter codec: `PARAMETER_SETTINGS_ENTRY` chunk reassembly over a caller-owned rx table and per-type decode, plus encoders that return `PARAMETER_READ`/`WRITE`, command-step and suppress-critical-errors frames for the caller to push. Loaded by the tool and the VTX Admin widget |
4871
| `SCRIPTS/ELRS/crsf_session.lua` | Opt-in stateful parameter client (`CRSFSession.new`, multi-instance): field store, load queue and retry scheduler, paced write queue, command state machine, and optional device discovery, link status and ELRS 1.x detection. Loaded by the tool and the VTX Admin widget |
4972
| `SCRIPTS/ELRS/crsf_elrsinfo.lua` | Opt-in TX-module state: DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, per-connection model-match latch. Loaded only by the telemetry widget |
73+
| `SCRIPTS/ELRS/msp.lua` | Opt-in MSP-over-CRSF codec: stateless encoders returning `(frameType, payload)` for `MSP_REQ`/`MSP_WRITE` and decoders for single-frame v1 `MSP_RESP`, plus the ELRS `RXTX_CONFIG` UID/phrase helpers. Loaded only by the bind tool |
74+
| `SCRIPTS/ELRS/defer.lua` | Single-slot `setTimeout`/`poll` timer; scheduling replaces the pending callback, which is what cancels a stale retry when a new action starts. Loaded only by the bind tool |
75+
| `SCRIPTS/ELRS/ui/lcd/text_edit.lua` | BW text editor replicating the firmware's `editName()` model-name semantics (rotary cycles the char, ENTER advances, long ENTER toggles case or commits on a space). Loaded only by the bind tool's BW UI. `ui/<display>/` is the library's home for shared UI components, mirroring the tools' own `ui/` split |
76+
| `SCRIPTS/ELRS/ui/lcd/alert.lua` | BW full-screen alert (MIDSIZE title, body lines, optional bottom action labels). Loaded by both tools' BW UIs |
77+
| `SCRIPTS/ELRS/ui/lvgl/dialogs.lua` | The color-LCD startup dialogs a tool can raise before it has a page -- the version gate and the missing-module notice. Both are terminal, so each takes the caller's `onExit` for the close box and the Exit button. Loaded by both tools' LVGL UIs |
78+
| `SCRIPTS/ELRS/loader.lua` | The tools' GC-guarded script loader: a full collection before each `loadScript` keeps fresh-install compile peaks from stacking. The one part consumers bootstrap with a bare `loadScript` |
79+
| `SCRIPTS/ELRS/edgetx_version.lua` | The one home of the minimum EdgeTX requirement (2.11.6 / 2.12.1 / 3.0). Each tool's `main.lua` checks it once and hands `deps.versionOk` to its UI chunk, whose `preCheck` owns the presentation. Keep `min_edgetx_version` in `edgetx.yml` in step |
5080
| `SCRIPTS/ELRS/sensors.lua` | Generic EdgeTX telemetry reader (`getSensorValue` with a cached name-to-ID lookup), not CRSF-specific. Loaded by `crsf.lua`, which exposes it to every consumer as `crsf.getSensorValue` |
51-
| `SCRIPTS/ELRS/file_storage.lua` | Generic key=value file persistence (`read`/`write`), schema-free. Loaded only by the VTX Admin widget |
81+
| `SCRIPTS/ELRS/file_storage.lua` | Generic key=value file persistence (`read`/`write`), schema-free. Loaded by the VTX Admin widget and the bind tool |
5282
| `SCRIPTS/ELRS/shim.lua` | `table.concat` polyfill for BW radios |
5383

5484
Frames are consumed pull-style. `crossfireTelemetryPop()` is destructive per script instance, and
5585
the firmware delivers every widget instance its own copy of each incoming frame, so **each script
56-
instance has exactly one draining consumer**: the tool and the VTX Admin widget drain through
57-
`session:drain()`, the telemetry widget through `elrsinfo:drain()`. A future widget needing two
58-
consumers must pop once and route the frames itself. `reassemble()` callers pass the field id they
86+
instance has exactly one draining consumer**: the config tool and the VTX Admin widget drain through
87+
`session:drain()`, the telemetry widget through `elrsinfo:drain()`, and the bind tool through
88+
`crsf.drain(App, App.onFrame)`. A future widget needing two consumers must pop once and route the
89+
frames itself. `reassemble()` callers pass the field id they
5990
are waiting for (strict), or `data[3]` to accept any field from their device (`acceptUnsolicited`,
6091
used by VTX Admin so sibling instances stay in sync from each other's answers).
6192

@@ -96,6 +127,18 @@ The simulator supports multiple test scenarios, configurable via the `config.sce
96127
| `no_module` | No CRSF module found. Triggers "No Module Found" error dialog. |
97128
| `critical_error` | TX + RX connected with a critical baud-rate error flag. Triggers the warning screen; the suppress write clears it. |
98129

130+
### MSP bind traffic
131+
132+
The mock answers the bind tool's MSP `RXTX_CONFIG` traffic: a UID read (`MSP_REQ`) is served from a
133+
per-device `mspUid` table, and a phrase write (`MSP_WRITE`) rederives the target's UID through a
134+
deterministic pseudo-hash, so equal phrases give equal UIDs (the property the Both flow
135+
demonstrates; the bytes need not match the firmware's MD5). Both devices start on the same UID,
136+
because a reachable receiver whose UID differs from the transmitter's is a state the radios cannot
137+
be in. The RX answers only while the scenario keeps it reachable, and writes are unacknowledged just
138+
like the real firmware. `FRAMETYPE_COMMAND` bind requests are log-only -- note that the firmware
139+
handles that command identically at either address (`EnterBindingModeSafely`), so the "unbind"
140+
button puts the receiver into bind mode rather than erasing its binding.
141+
99142
`config.maxPacketBytes` (default 64, `CRSF_MAX_PACKET_LEN`) is the largest frame the mock handset
100143
link carries. Parameter entries longer than `maxPacketBytes - 8` are chunked exactly as
101144
`CRSFEndpoint::sendParameter` does, so lowering it -- real firmware shrinks it on slow baud rates in

edgetx.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ package:
1313
screenshots:
1414
- screenshots/tool_main.png
1515
- screenshots/tool_main_bw.png
16+
- screenshots/tool_bind.png
17+
- screenshots/tool_bind_bw.png
1618
- screenshots/widget_telemetry_fullscren.png
1719
- screenshots/widget_vtxadmin_fullscreen.png
1820
- screenshots/widgets.png
@@ -38,6 +40,13 @@ tools:
3840
depends:
3941
- ELRS
4042

43+
- name: ExpressLRSBind
44+
path: SCRIPTS/TOOLS/ExpressLRSBind
45+
depends:
46+
- ELRS
47+
exclude:
48+
- history.txt
49+
4150
widgets:
4251
- name: ELRSTelemetry
4352
path: WIDGETS/ELRSTelemetry

screenshots/tool_bind.png

32.6 KB
Loading

screenshots/tool_bind_bw.png

6.25 KB
Loading

src/SCRIPTS/CRSFSimulator/csrfsimulator.lua

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ local CRSF = {
7373
FRAMETYPE_PARAMETER_READ = 0x2C,
7474
FRAMETYPE_PARAMETER_WRITE = 0x2D,
7575
FRAMETYPE_ELRS_STATUS = 0x2E,
76+
FRAMETYPE_COMMAND = 0x32,
77+
FRAMETYPE_MSP_REQ = 0x7A,
78+
FRAMETYPE_MSP_RESP = 0x7B,
79+
FRAMETYPE_MSP_WRITE = 0x7C,
80+
81+
-- MSP-over-CRSF (single-frame v1: version 1 | start-of-frame | seq 0)
82+
MSP_HEADER_V1 = 0x30,
83+
MSP_ELRS_RXTX_CONFIG = 0x2D,
84+
MSP_RXTX_UID = 0x00,
85+
MSP_RXTX_BIND_PHRASE = 0x01,
7686

7787
-- Addresses
7888
ADDRESS_BROADCAST = 0x00,
@@ -723,8 +733,8 @@ local rxDevice = {
723733
id = 3,
724734
parent = 0,
725735
type = CRSF.TEXT_SELECTION,
726-
name = "Ant. Mode",
727-
options = "Antenna A;Antenna B;Diversity",
736+
name = "Antenna Mode",
737+
options = "Antenna 1;Antenna 2;Diversity",
728738
value = 2,
729739
units = "",
730740
},
@@ -1244,6 +1254,53 @@ local function handleCommandWrite(device, param, newStatus)
12441254
param.info = state.info
12451255
end
12461256

1257+
-- ============================================================================
1258+
-- MSP bind UID state
1259+
-- ============================================================================
1260+
1261+
-- Per-device bind UID, read and written over MSP RXTX_CONFIG. Both start
1262+
-- on the same UID: a receiver only answers over a link, and a link only
1263+
-- exists between devices that already share one, so a reachable RX whose
1264+
-- UID differs from the TX's is a state the radios cannot be in.
1265+
local mspUid = {
1266+
[CRSF.ADDRESS_TX] = { 13, 213, 105, 32, 0, 1 },
1267+
[CRSF.ADDRESS_RX] = { 13, 213, 105, 32, 0, 1 },
1268+
}
1269+
1270+
--- Derive a deterministic 6-byte UID from bind-phrase bytes: the same
1271+
-- phrase always yields the same UID, so a phrase written to both devices
1272+
-- produces matching UIDs. Equality is the property the tool demonstrates;
1273+
-- the bytes themselves need not match the firmware's MD5 derivation.
1274+
local function deriveUid(chars)
1275+
local uid = { 0, 0, 0, 0, 0, 0 }
1276+
local acc = 0
1277+
for i = 1, #chars do
1278+
acc = (acc + chars[i] * i) % 251
1279+
local slot = (i - 1) % 6 + 1
1280+
uid[slot] = (uid[slot] + acc + chars[i]) % 256
1281+
end
1282+
return uid
1283+
end
1284+
1285+
--- Build an MSP_RESP payload answering a RXTX_CONFIG/UID read.
1286+
-- Layout mirrors the request: header, size (subcmd + 6 bytes), fn, subcmd.
1287+
local function encodeMspUidResponse(deviceId, destAddr, uid)
1288+
return {
1289+
destAddr,
1290+
deviceId,
1291+
CRSF.MSP_HEADER_V1,
1292+
7,
1293+
CRSF.MSP_ELRS_RXTX_CONFIG,
1294+
CRSF.MSP_RXTX_UID,
1295+
uid[1],
1296+
uid[2],
1297+
uid[3],
1298+
uid[4],
1299+
uid[5],
1300+
uid[6],
1301+
}
1302+
end
1303+
12471304
-- ============================================================================
12481305
-- mockPush: Processes commands sent by the Lua script
12491306
-- ============================================================================
@@ -1456,6 +1513,46 @@ local function mockPush(command, data)
14561513
end
14571514
end
14581515
return true
1516+
elseif command == CRSF.FRAMETYPE_MSP_REQ then
1517+
-- MSP read: data = { deviceId, handsetId, header, size, fn, subcmd }
1518+
local deviceId = data[1]
1519+
local replyTo = data[2] or CRSF.ADDRESS_HANDSET
1520+
if data[5] == CRSF.MSP_ELRS_RXTX_CONFIG and data[6] == CRSF.MSP_RXTX_UID then
1521+
if deviceId == CRSF.ADDRESS_TX then
1522+
queuePush(CRSF.FRAMETYPE_MSP_RESP, encodeMspUidResponse(deviceId, replyTo, mspUid[deviceId]))
1523+
elseif deviceId == CRSF.ADDRESS_RX and isRxAvailable() then
1524+
-- Relayed over the air: arrives in the next poll cycle. An absent RX
1525+
-- answers nothing, which is what drives the tool's bounded retry.
1526+
queuePushDeferred(CRSF.FRAMETYPE_MSP_RESP, encodeMspUidResponse(deviceId, replyTo, mspUid[deviceId]))
1527+
end
1528+
end
1529+
return true
1530+
elseif command == CRSF.FRAMETYPE_MSP_WRITE then
1531+
-- MSP write: data = { deviceId, handsetId, header, size, fn, subcmd, ... }.
1532+
-- The firmware sends no acknowledgement; the tool re-reads the UID.
1533+
local deviceId = data[1]
1534+
local reachable = deviceId == CRSF.ADDRESS_TX or (deviceId == CRSF.ADDRESS_RX and isRxAvailable())
1535+
if data[5] == CRSF.MSP_ELRS_RXTX_CONFIG and reachable then
1536+
if data[6] == CRSF.MSP_RXTX_BIND_PHRASE then
1537+
-- size counts subcmd + phrase bytes, so the phrase ends at data[5 + size]
1538+
local chars = {}
1539+
for i = 7, 5 + (data[4] or 0) do
1540+
chars[#chars + 1] = data[i]
1541+
end
1542+
mspUid[deviceId] = deriveUid(chars)
1543+
elseif data[6] == CRSF.MSP_RXTX_UID then
1544+
local uid = {}
1545+
for i = 1, 6 do
1546+
uid[i] = data[6 + i] or 0
1547+
end
1548+
mspUid[deviceId] = uid
1549+
end
1550+
end
1551+
return true
1552+
elseif command == CRSF.FRAMETYPE_COMMAND then
1553+
-- Bind/unbind requests. Log-only: the push line above already records
1554+
-- the destination, and the mock has no bound-state to change.
1555+
return true
14591556
end
14601557

14611558
-- Unknown command - ignore

src/SCRIPTS/ELRS/crsf.lua

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ CRSF.CONST = {
3636
FRAMETYPE_PARAMETER_READ = 0x2C,
3737
FRAMETYPE_PARAMETER_WRITE = 0x2D,
3838
FRAMETYPE_ELRS_STATUS = 0x2E,
39+
FRAMETYPE_COMMAND = 0x32,
40+
FRAMETYPE_MSP_REQ = 0x7A,
41+
FRAMETYPE_MSP_RESP = 0x7B,
42+
FRAMETYPE_MSP_WRITE = 0x7C,
43+
44+
-- COMMAND (0x32) subcommands (crsf_command_e / crsf_subcommand_e)
45+
COMMAND_SUBCMD_RX = 0x10,
46+
COMMAND_SUBCMD_RX_BIND = 0x01,
3947

4048
-- Field types (for parsing PARAMETER_SETTINGS_ENTRY responses)
4149
FIELD_UINT8 = 0,
@@ -270,6 +278,19 @@ function CRSF:requestElrsStatus()
270278
CRSF.push(CRSF.CONST.FRAMETYPE_PARAMETER_WRITE, { CRSF.CONST.ADDRESS_TX, CRSF.CONST.ADDRESS_HANDSET_ELRS, 0, 0 })
271279
end
272280

281+
--- Send a COMMAND bind request. Addressed to the TX module it enters bind
282+
-- mode, transmitting to any RX waiting in bind mode; addressed to the RX it
283+
-- unbinds a connected receiver.
284+
-- @param dest CRSF device address (use CRSF.CONST.ADDRESS_*); nil targets the TX
285+
function CRSF:sendBindCommand(dest)
286+
CRSF.push(CRSF.CONST.FRAMETYPE_COMMAND, {
287+
dest or CRSF.CONST.ADDRESS_TX,
288+
CRSF.CONST.ADDRESS_HANDSET,
289+
CRSF.CONST.COMMAND_SUBCMD_RX,
290+
CRSF.CONST.COMMAND_SUBCMD_RX_BIND,
291+
})
292+
end
293+
273294
-- ============================================================================
274295
-- Return singleton
275296
-- ============================================================================

0 commit comments

Comments
 (0)