Elekstube usermod enhancements
Coloring of grayscale images Dimming control from configurable segment
This commit is contained in:
parent
02b08939cd
commit
ad301fd087
@ -35,6 +35,8 @@ private:
|
|||||||
|
|
||||||
uint16_t output_buffer[TFT_HEIGHT][TFT_WIDTH];
|
uint16_t output_buffer[TFT_HEIGHT][TFT_WIDTH];
|
||||||
int16_t w = 135, h = 240, x = 0, y = 0, bufferedDigit = 255;
|
int16_t w = 135, h = 240, x = 0, y = 0, bufferedDigit = 255;
|
||||||
|
uint16_t digitR, digitG, digitB, dimming = 255;
|
||||||
|
uint32_t digitColor = 0;
|
||||||
|
|
||||||
void drawBuffer() {
|
void drawBuffer() {
|
||||||
bool oldSwapBytes = getSwapBytes();
|
bool oldSwapBytes = getSwapBytes();
|
||||||
@ -75,10 +77,6 @@ private:
|
|||||||
|
|
||||||
if (!realtimeMode || realtimeOverride) strip.service();
|
if (!realtimeMode || realtimeOverride) strip.service();
|
||||||
|
|
||||||
#ifdef ELEKSTUBE_DIMMING
|
|
||||||
dimming=bri;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// 0,0 coordinates are top left
|
// 0,0 coordinates are top left
|
||||||
for (row = 0; row < h; row++) {
|
for (row = 0; row < h; row++) {
|
||||||
|
|
||||||
@ -88,7 +86,7 @@ private:
|
|||||||
// Colors are already in 16-bit R5, G6, B5 format
|
// Colors are already in 16-bit R5, G6, B5 format
|
||||||
for (col = 0; col < w; col++)
|
for (col = 0; col < w; col++)
|
||||||
{
|
{
|
||||||
if (dimming == 255) { // not needed, copy directly
|
if (dimming == 255 && !digitColor) { // not needed, copy directly
|
||||||
output_buffer[row][col] = (lineBuffer[col*2+1] << 8) | (lineBuffer[col*2]);
|
output_buffer[row][col] = (lineBuffer[col*2+1] << 8) | (lineBuffer[col*2]);
|
||||||
} else {
|
} else {
|
||||||
// 16 BPP pixel format: R5, G6, B5 ; bin: RRRR RGGG GGGB BBBB
|
// 16 BPP pixel format: R5, G6, B5 ; bin: RRRR RGGG GGGB BBBB
|
||||||
@ -98,12 +96,14 @@ private:
|
|||||||
r = (PixM) & 0xF8;
|
r = (PixM) & 0xF8;
|
||||||
g = ((PixM << 5) | (PixL >> 3)) & 0xFC;
|
g = ((PixM << 5) | (PixL >> 3)) & 0xFC;
|
||||||
b = (PixL << 3) & 0xF8;
|
b = (PixL << 3) & 0xF8;
|
||||||
r *= dimming;
|
r *= dimming; g *= dimming; b *= dimming;
|
||||||
g *= dimming;
|
r = r >> 8; g = g >> 8; b = b >> 8;
|
||||||
b *= dimming;
|
if (digitColor) { // grayscale pixel coloring
|
||||||
r = r >> 8;
|
uint8_t l = (r > g) ? ((r > b) ? r:b) : ((g > b) ? g:b);
|
||||||
g = g >> 8;
|
r = g = b = l;
|
||||||
b = b >> 8;
|
r *= digitR; g *= digitG; b *= digitB;
|
||||||
|
r = r >> 8; g = g >> 8; b = b >> 8;
|
||||||
|
}
|
||||||
output_buffer[row][col] = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
|
output_buffer[row][col] = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -176,9 +176,6 @@ private:
|
|||||||
bmpFS.read(lineBuffer, sizeof(lineBuffer));
|
bmpFS.read(lineBuffer, sizeof(lineBuffer));
|
||||||
uint8_t* bptr = lineBuffer;
|
uint8_t* bptr = lineBuffer;
|
||||||
|
|
||||||
#ifdef ELEKSTUBE_DIMMING
|
|
||||||
dimming=bri;
|
|
||||||
#endif
|
|
||||||
// Convert 24 to 16 bit colors while copying to output buffer.
|
// Convert 24 to 16 bit colors while copying to output buffer.
|
||||||
for (uint16_t col = 0; col < w; col++)
|
for (uint16_t col = 0; col < w; col++)
|
||||||
{
|
{
|
||||||
@ -202,12 +199,15 @@ private:
|
|||||||
b = c; g = c >> 8; r = c >> 16;
|
b = c; g = c >> 8; r = c >> 16;
|
||||||
}
|
}
|
||||||
if (dimming != 255) { // only dimm when needed
|
if (dimming != 255) { // only dimm when needed
|
||||||
b *= dimming;
|
r *= dimming; g *= dimming; b *= dimming;
|
||||||
g *= dimming;
|
r = r >> 8; g = g >> 8; b = b >> 8;
|
||||||
r *= dimming;
|
}
|
||||||
b = b >> 8;
|
if (digitColor) { // grayscale pixel coloring
|
||||||
g = g >> 8;
|
uint8_t l = (r > g) ? ((r > b) ? r:b) : ((g > b) ? g:b);
|
||||||
r = r >> 8;
|
r = g = b = l;
|
||||||
|
|
||||||
|
r *= digitR; g *= digitG; b *= digitB;
|
||||||
|
r = r >> 8; g = g >> 8; b = b >> 8;
|
||||||
}
|
}
|
||||||
output_buffer[row][col] = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | ((b & 0xFF) >> 3);
|
output_buffer[row][col] = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | ((b & 0xFF) >> 3);
|
||||||
}
|
}
|
||||||
@ -250,10 +250,6 @@ private:
|
|||||||
|
|
||||||
uint8_t lineBuffer[w * 2];
|
uint8_t lineBuffer[w * 2];
|
||||||
|
|
||||||
#ifdef ELEKSTUBE_DIMMING
|
|
||||||
dimming=bri;
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if (!realtimeMode || realtimeOverride) strip.service();
|
if (!realtimeMode || realtimeOverride) strip.service();
|
||||||
|
|
||||||
// 0,0 coordinates are top left
|
// 0,0 coordinates are top left
|
||||||
@ -265,7 +261,7 @@ private:
|
|||||||
// Colors are already in 16-bit R5, G6, B5 format
|
// Colors are already in 16-bit R5, G6, B5 format
|
||||||
for (col = 0; col < w; col++)
|
for (col = 0; col < w; col++)
|
||||||
{
|
{
|
||||||
if (dimming == 255) { // not needed, copy directly
|
if (dimming == 255 && !digitColor) { // not needed, copy directly
|
||||||
output_buffer[row][col+x] = (lineBuffer[col*2+1] << 8) | (lineBuffer[col*2]);
|
output_buffer[row][col+x] = (lineBuffer[col*2+1] << 8) | (lineBuffer[col*2]);
|
||||||
} else {
|
} else {
|
||||||
// 16 BPP pixel format: R5, G6, B5 ; bin: RRRR RGGG GGGB BBBB
|
// 16 BPP pixel format: R5, G6, B5 ; bin: RRRR RGGG GGGB BBBB
|
||||||
@ -275,12 +271,14 @@ private:
|
|||||||
r = (PixM) & 0xF8;
|
r = (PixM) & 0xF8;
|
||||||
g = ((PixM << 5) | (PixL >> 3)) & 0xFC;
|
g = ((PixM << 5) | (PixL >> 3)) & 0xFC;
|
||||||
b = (PixL << 3) & 0xF8;
|
b = (PixL << 3) & 0xF8;
|
||||||
r *= dimming;
|
r *= dimming; g *= dimming; b *= dimming;
|
||||||
g *= dimming;
|
r = r >> 8; g = g >> 8; b = b >> 8;
|
||||||
b *= dimming;
|
if (digitColor) { // grayscale pixel coloring
|
||||||
r = r >> 8;
|
uint8_t l = (r > g) ? ((r > b) ? r:b) : ((g > b) ? g:b);
|
||||||
g = g >> 8;
|
r = g = b = l;
|
||||||
b = b >> 8;
|
r *= digitR; g *= digitG; b *= digitB;
|
||||||
|
r = r >> 8; g = g >> 8; b = b >> 8;
|
||||||
|
}
|
||||||
output_buffer[row][col+x] = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
|
output_buffer[row][col+x] = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -302,6 +300,9 @@ public:
|
|||||||
// A digit of 0xFF means blank the screen.
|
// A digit of 0xFF means blank the screen.
|
||||||
const static uint8_t blanked = 255;
|
const static uint8_t blanked = 255;
|
||||||
|
|
||||||
|
uint8_t tubeSegment = 1;
|
||||||
|
uint8_t digitOffset = 0;
|
||||||
|
|
||||||
void begin() {
|
void begin() {
|
||||||
pinMode(TFT_ENABLE_PIN, OUTPUT);
|
pinMode(TFT_ENABLE_PIN, OUTPUT);
|
||||||
digitalWrite(TFT_ENABLE_PIN, HIGH); //enable displays on boot
|
digitalWrite(TFT_ENABLE_PIN, HIGH); //enable displays on boot
|
||||||
@ -317,13 +318,15 @@ public:
|
|||||||
void showDigit(uint8_t digit) {
|
void showDigit(uint8_t digit) {
|
||||||
chip_select.setDigit(digit);
|
chip_select.setDigit(digit);
|
||||||
uint8_t digitToDraw = digits[digit];
|
uint8_t digitToDraw = digits[digit];
|
||||||
|
if (digitToDraw < 10) digitToDraw += digitOffset;
|
||||||
|
|
||||||
if (digitToDraw == blanked) {
|
if (digitToDraw == blanked) {
|
||||||
fillScreen(TFT_BLACK); return;
|
fillScreen(TFT_BLACK); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if last digit was the same, skip loading from FS to buffer
|
// if last digit was the same, skip loading from FS to buffer
|
||||||
if (digitToDraw == bufferedDigit) drawBuffer();
|
if (!digitColor && digitToDraw == bufferedDigit) drawBuffer();
|
||||||
|
digitR = R(digitColor); digitG = G(digitColor); digitB = B(digitColor);
|
||||||
|
|
||||||
// Filenames are no bigger than "254.bmp\0"
|
// Filenames are no bigger than "254.bmp\0"
|
||||||
char file_name[10];
|
char file_name[10];
|
||||||
@ -349,6 +352,18 @@ public:
|
|||||||
uint8_t old_value = digits[digit];
|
uint8_t old_value = digits[digit];
|
||||||
digits[digit] = value;
|
digits[digit] = value;
|
||||||
|
|
||||||
|
// Color in grayscale bitmaps if Segment 1 exists
|
||||||
|
// TODO If secondary and tertiary are black, color all in primary,
|
||||||
|
// else color first three from Seg 1 color slots and last three from Seg 2 color slots
|
||||||
|
WS2812FX::Segment& seg1 = strip.getSegment(tubeSegment);
|
||||||
|
if (seg1.isActive()) {
|
||||||
|
digitColor = strip.getPixelColor(seg1.start + digit);
|
||||||
|
dimming = seg1.opacity;
|
||||||
|
} else {
|
||||||
|
digitColor = 0;
|
||||||
|
dimming = 255;
|
||||||
|
}
|
||||||
|
|
||||||
if (show != no && (old_value != value || show == force)) {
|
if (show != no && (old_value != value || show == force)) {
|
||||||
showDigit(digit);
|
showDigit(digit);
|
||||||
}
|
}
|
||||||
|
@ -5,16 +5,17 @@ It enables running all WLED effects on the background SK6812 lighting, while dis
|
|||||||
Code is largely based on https://github.com/SmittyHalibut/EleksTubeHAX by Mark Smith!
|
Code is largely based on https://github.com/SmittyHalibut/EleksTubeHAX by Mark Smith!
|
||||||
|
|
||||||
Supported:
|
Supported:
|
||||||
- Display with custom bitmaps or raw RGB565 images (.bin) from filesystem
|
- Display with custom bitmaps (.bmp) or raw RGB565 images (.bin) from filesystem
|
||||||
- Background lighting
|
- Background lighting
|
||||||
- Power button
|
- All 4 hardware buttons
|
||||||
- RTC (with RTC usermod)
|
- RTC (with RTC usermod)
|
||||||
- Standard WLED time features (NTP, DST, timezones)
|
- Standard WLED time features (NTP, DST, timezones)
|
||||||
|
|
||||||
Not supported:
|
Not supported:
|
||||||
- 3 navigation buttons, on-device setup
|
- On-device setup with buttons (WiFi setup only)
|
||||||
|
|
||||||
Your images must be exactly 135 pixels wide and 1-240 pixels high.
|
Your images must be 1-135 pixels wide and 1-240 pixels high.
|
||||||
|
For BMP, 1, 4, 8, and 24 bits per pixel formats are supported.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@ -25,7 +26,20 @@ Use LED pin 12, relay pin 27 and button pin 34.
|
|||||||
|
|
||||||
## Use of RGB565 images
|
## Use of RGB565 images
|
||||||
|
|
||||||
Binary 16-bit per pixel RGB565 format `.bin` images are now supported. This has the benefit of only using 2/3rds of the file size a `.bmp` has.
|
Binary 16-bit per pixel RGB565 format `.bin` and `.clk` images are now supported. This has the benefit of only using 2/3rds of the file size a 24 BPP `.bmp` has.
|
||||||
The drawback is that this format cannot be handled by common image programs and that an extra conversion step is needed.
|
The drawback is that this format cannot be handled by common image programs and that an extra conversion step is needed.
|
||||||
You can use https://lvgl.io/tools/imageconverter to convert your .bmp to a .bin file (settings `True color` and `Binary RGB565`)
|
You can use https://lvgl.io/tools/imageconverter to convert your .bmp to a .bin file (settings `True color` and `Binary RGB565`).
|
||||||
Thank you to @RedNax67 for adding .bin support.
|
Thank you to @RedNax67 for adding .bin and .clk support.
|
||||||
|
For most clockface designs, using 4 or 8 BPP BMP formats will save even more file size:
|
||||||
|
|
||||||
|
| Bits per pixel | File size in kB (for 135x240 img) | % of 24 BPP BMP | Max unique colors
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
24 | 98 | 100% | 16M (66K)
|
||||||
|
16 (.clk) | 64.8 | 66% | 66K
|
||||||
|
8 | 33.7 | 34% | 256
|
||||||
|
4 | 16.4 | 17% | 16
|
||||||
|
1 | 4.9 | 5% | 2
|
||||||
|
|
||||||
|
Comparison 1 vs. 4 vs. 8 vs. 24 BPP. With this clockface on the actual clock, 4 bit looks good, and 8 bit is almost indistinguishable from 24 bit.
|
||||||
|
|
||||||
|
![comparison](https://user-images.githubusercontent.com/21045690/156899667-5b55ed9f-6e03-4066-b2aa-1260e9570369.png)
|
@ -6,6 +6,11 @@
|
|||||||
|
|
||||||
class ElekstubeIPSUsermod : public Usermod {
|
class ElekstubeIPSUsermod : public Usermod {
|
||||||
private:
|
private:
|
||||||
|
// strings to reduce flash memory usage (used more than twice)
|
||||||
|
static const char _name[];
|
||||||
|
static const char _tubeSeg[];
|
||||||
|
static const char _digitOffset[];
|
||||||
|
|
||||||
TFTs tfts;
|
TFTs tfts;
|
||||||
void updateClockDisplay(TFTs::show_t show=TFTs::yes) {
|
void updateClockDisplay(TFTs::show_t show=TFTs::yes) {
|
||||||
bool set[6] = {false};
|
bool set[6] = {false};
|
||||||
@ -40,6 +45,7 @@ class ElekstubeIPSUsermod : public Usermod {
|
|||||||
public:
|
public:
|
||||||
|
|
||||||
uint8_t lastBri;
|
uint8_t lastBri;
|
||||||
|
uint32_t lastCols[6];
|
||||||
TFTs::show_t fshow=TFTs::yes;
|
TFTs::show_t fshow=TFTs::yes;
|
||||||
|
|
||||||
void setup() {
|
void setup() {
|
||||||
@ -52,17 +58,85 @@ class ElekstubeIPSUsermod : public Usermod {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
if (toki.isTick()) {
|
if (!toki.isTick()) return;
|
||||||
updateLocalTime();
|
updateLocalTime();
|
||||||
#ifdef ELEKSTUBE_DIMMING
|
|
||||||
if (bri != lastBri) {
|
WS2812FX::Segment& seg1 = strip.getSegment(tfts.tubeSegment);
|
||||||
fshow=TFTs::force;
|
if (seg1.isActive()) {
|
||||||
lastBri = bri;
|
bool update = false;
|
||||||
|
if (seg1.opacity != lastBri) update = true;
|
||||||
|
lastBri = seg1.opacity;
|
||||||
|
for (uint8_t i = 0; i < 6; i++) {
|
||||||
|
uint32_t c = strip.getPixelColor(seg1.start + i);
|
||||||
|
if (c != lastCols[i]) update = true;
|
||||||
|
lastCols[i] = c;
|
||||||
}
|
}
|
||||||
#endif
|
if (update) fshow=TFTs::force;
|
||||||
|
} else if (lastCols[0] != 0) { // Segment 1 deleted
|
||||||
|
fshow=TFTs::force;
|
||||||
|
lastCols[0] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
updateClockDisplay(fshow);
|
updateClockDisplay(fshow);
|
||||||
fshow=TFTs::yes;
|
fshow=TFTs::yes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* addToConfig() (called from set.cpp) stores persistent properties to cfg.json
|
||||||
|
*/
|
||||||
|
void addToConfig(JsonObject &root) {
|
||||||
|
// we add JSON object: {"EleksTubeIPS": {"tubeSegment": 1, "digitOffset": 0}}
|
||||||
|
JsonObject top = root.createNestedObject(FPSTR(_name)); // usermodname
|
||||||
|
top[FPSTR(_tubeSeg)] = tfts.tubeSegment;
|
||||||
|
top[FPSTR(_digitOffset)] = tfts.digitOffset;
|
||||||
|
DEBUG_PRINTLN(F("EleksTube config saved."));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* readFromConfig() is called before setup() to populate properties from values stored in cfg.json
|
||||||
|
*
|
||||||
|
* The function should return true if configuration was successfully loaded or false if there was no configuration.
|
||||||
|
*/
|
||||||
|
bool readFromConfig(JsonObject &root) {
|
||||||
|
// we look for JSON object: {"EleksTubeIPS": {"tubeSegment": 1, "digitOffset": 0}}
|
||||||
|
DEBUG_PRINT(FPSTR(_name));
|
||||||
|
|
||||||
|
JsonObject top = root[FPSTR(_name)];
|
||||||
|
if (top.isNull()) {
|
||||||
|
DEBUG_PRINTLN(F(": No config found. (Using defaults.)"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
tfts.tubeSegment = top[FPSTR(_tubeSeg)] | tfts.tubeSegment;
|
||||||
|
uint8_t digitOffsetPrev = tfts.digitOffset;
|
||||||
|
tfts.digitOffset = top[FPSTR(_digitOffset)] | tfts.digitOffset;
|
||||||
|
if (tfts.digitOffset > 240) tfts.digitOffset = 240;
|
||||||
|
if (tfts.digitOffset != digitOffsetPrev) fshow=TFTs::force;
|
||||||
|
|
||||||
|
// use "return !top["newestParameter"].isNull();" when updating Usermod with new features
|
||||||
|
return !top[FPSTR(_digitOffset)].isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* addToJsonState() can be used to add custom entries to the /json/state part of the JSON API (state object).
|
||||||
|
* Values in the state object may be modified by connected clients
|
||||||
|
*/
|
||||||
|
void addToJsonState(JsonObject& root)
|
||||||
|
{
|
||||||
|
root[FPSTR(_digitOffset)] = tfts.digitOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* readFromJsonState() can be used to receive data clients send to the /json/state part of the JSON API (state object).
|
||||||
|
* Values in the state object may be modified by connected clients
|
||||||
|
*/
|
||||||
|
void readFromJsonState(JsonObject& root)
|
||||||
|
{
|
||||||
|
uint8_t digitOffsetPrev = tfts.digitOffset;
|
||||||
|
tfts.digitOffset = root[FPSTR(_digitOffset)] | tfts.digitOffset;
|
||||||
|
if (tfts.digitOffset > 240) tfts.digitOffset = 240;
|
||||||
|
if (tfts.digitOffset != digitOffsetPrev) fshow=TFTs::force;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint16_t getId()
|
uint16_t getId()
|
||||||
@ -70,3 +144,8 @@ class ElekstubeIPSUsermod : public Usermod {
|
|||||||
return USERMOD_ID_ELEKSTUBE_IPS;
|
return USERMOD_ID_ELEKSTUBE_IPS;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// strings to reduce flash memory usage (used more than twice)
|
||||||
|
const char ElekstubeIPSUsermod::_name[] PROGMEM = "EleksTubeIPS";
|
||||||
|
const char ElekstubeIPSUsermod::_tubeSeg[] PROGMEM = "tubeSegment";
|
||||||
|
const char ElekstubeIPSUsermod::_digitOffset[] PROGMEM = "digitOffset";
|
||||||
|
@ -652,19 +652,16 @@ void sendSysInfoUDP()
|
|||||||
uint8_t sequenceNumber = 0; // this needs to be shared across all outputs
|
uint8_t sequenceNumber = 0; // this needs to be shared across all outputs
|
||||||
|
|
||||||
uint8_t realtimeBroadcast(uint8_t type, IPAddress client, uint16_t length, uint8_t *buffer, uint8_t bri, bool isRGBW) {
|
uint8_t realtimeBroadcast(uint8_t type, IPAddress client, uint16_t length, uint8_t *buffer, uint8_t bri, bool isRGBW) {
|
||||||
if (!interfacesInited) return 1; // network not initialised
|
if (!interfacesInited || !client[0] || !length) return 1; // network not initialised or dummy/unset IP address
|
||||||
|
|
||||||
WiFiUDP ddpUdp;
|
WiFiUDP ddpUdp;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 0: // DDP
|
case 0: // DDP
|
||||||
{
|
{
|
||||||
// calclate the number of UDP packets we need to send
|
// calculate the number of UDP packets we need to send
|
||||||
uint16_t channelCount = length * 3; // 1 channel for every R,G,B value
|
uint16_t channelCount = length * 3; // 1 channel for every R,G,B value
|
||||||
uint16_t packetCount = channelCount / DDP_CHANNELS_PER_PACKET;
|
uint16_t packetCount = ((channelCount-1) / DDP_CHANNELS_PER_PACKET) +1;
|
||||||
if (channelCount % DDP_CHANNELS_PER_PACKET) {
|
|
||||||
packetCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// there are 3 channels per RGB pixel
|
// there are 3 channels per RGB pixel
|
||||||
uint32_t channel = 0; // TODO: allow specifying the start channel
|
uint32_t channel = 0; // TODO: allow specifying the start channel
|
||||||
|
Loading…
Reference in New Issue
Block a user