• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mcallegari / qlcplus / 7252848206

18 Dec 2023 07:26PM UTC coverage: 32.067% (+0.001%) from 32.066%
7252848206

push

github

mcallegari
Code style review #1427

199 of 628 new or added lines in 101 files covered. (31.69%)

8 existing lines in 2 files now uncovered.

15169 of 47304 relevant lines covered (32.07%)

23733.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

64.77
/engine/src/universe.cpp
1
/*
2
  Q Light Controller Plus
3
  universe.cpp
4

5
  Copyright (c) Massimo Callegari
6

7
  Licensed under the Apache License, Version 2.0 (the "License");
8
  you may not use this file except in compliance with the License.
9
  You may obtain a copy of the License at
10

11
      http://www.apache.org/licenses/LICENSE-2.0.txt
12

13
  Unless required by applicable law or agreed to in writing, software
14
  distributed under the License is distributed on an "AS IS" BASIS,
15
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
  See the License for the specific language governing permissions and
17
  limitations under the License.
18
*/
19

20
#include <QXmlStreamReader>
21
#include <QXmlStreamWriter>
22
#include <QDebug>
23
#include <math.h>
24

25
#include "channelmodifier.h"
26
#include "inputoutputmap.h"
27
#include "genericfader.h"
28
#include "qlcioplugin.h"
29
#include "outputpatch.h"
30
#include "grandmaster.h"
31
#include "mastertimer.h"
32
#include "inputpatch.h"
33
#include "qlcmacros.h"
34
#include "universe.h"
35
#include "qlcfile.h"
36
#include "utils.h"
37

38
#define RELATIVE_ZERO 127
39

40
#define KXMLUniverseNormalBlend "Normal"
41
#define KXMLUniverseMaskBlend "Mask"
42
#define KXMLUniverseAdditiveBlend "Additive"
43
#define KXMLUniverseSubtractiveBlend "Subtractive"
44

45
Universe::Universe(quint32 id, GrandMaster *gm, QObject *parent)
845✔
46
    : QThread(parent)
47
    , m_id(id)
48
    , m_grandMaster(gm)
49
    , m_passthrough(false)
50
    , m_monitor(false)
51
    , m_inputPatch(NULL)
52
    , m_fbPatch(NULL)
53
    , m_channelsMask(new QByteArray(UNIVERSE_SIZE, char(0)))
845✔
54
    , m_modifiedZeroValues(new QByteArray(UNIVERSE_SIZE, char(0)))
845✔
55
    , m_usedChannels(0)
56
    , m_totalChannels(0)
57
    , m_totalChannelsChanged(false)
58
    , m_intensityChannelsChanged(false)
59
    , m_preGMValues(new QByteArray(UNIVERSE_SIZE, char(0)))
845✔
60
    , m_postGMValues(new QByteArray(UNIVERSE_SIZE, char(0)))
845✔
61
    , m_lastPostGMValues(new QByteArray(UNIVERSE_SIZE, char(0)))
845✔
62
    , m_blackoutValues(new QByteArray(UNIVERSE_SIZE, char(0)))
845✔
63
    , m_passthroughValues()
5,915✔
64
{
65
    m_relativeValues.fill(0, UNIVERSE_SIZE);
845✔
66
    m_modifiers.fill(NULL, UNIVERSE_SIZE);
845✔
67

68
    m_name = QString("Universe %1").arg(id + 1);
845✔
69

70
    connect(m_grandMaster, SIGNAL(valueChanged(uchar)),
845✔
71
            this, SLOT(slotGMValueChanged()));
72
}
845✔
73

74
Universe::~Universe()
1,662✔
75
{
76
    if (isRunning() == true)
831✔
77
    {
78
        // isRunning is inconsistent with m_running,
79
        // so double check if the thread is really in the run loop
80
        while (m_running == false)
×
81
            usleep(10000);
×
82

83
        m_running = false;
×
84
        wait(1000);
×
85
    }
86

87
    delete m_inputPatch;
831✔
88
    int opCount = m_outputPatchList.count();
831✔
89
    for (int i = 0; i < opCount; i++)
845✔
90
    {
91
        OutputPatch *patch = m_outputPatchList.takeLast();
14✔
92
        delete patch;
14✔
93
    }
94
    delete m_fbPatch;
831✔
95
}
1,662✔
96

97
void Universe::setName(QString name)
7✔
98
{
99
    if (name.isEmpty())
7✔
100
        m_name = QString("Universe %1").arg(m_id + 1);
×
101
    else
102
        m_name = name;
7✔
103
    emit nameChanged();
7✔
104
}
7✔
105

106
QString Universe::name() const
106✔
107
{
108
    return m_name;
106✔
109
}
110

111
void Universe::setID(quint32 id)
2✔
112
{
113
    m_id = id;
2✔
114
}
2✔
115

116
quint32 Universe::id() const
132,949✔
117
{
118
    return m_id;
132,949✔
119
}
120

121
ushort Universe::usedChannels()
53✔
122
{
123
    return m_usedChannels;
53✔
124
}
125

126
ushort Universe::totalChannels()
6✔
127
{
128
    return m_totalChannels;
6✔
129
}
130

131
bool Universe::hasChanged()
10,505,900✔
132
{
133
    bool changed =
134
        memcmp(m_lastPostGMValues->constData(), m_postGMValues->constData(), m_usedChannels) != 0;
10,505,900✔
135
    if (changed)
10,505,900✔
136
        memcpy(m_lastPostGMValues->data(), m_postGMValues->constData(), m_usedChannels);
133,964✔
137
    return changed;
10,505,900✔
138
}
139

140
void Universe::setPassthrough(bool enable)
6✔
141
{
142
    if (enable == m_passthrough)
6✔
143
        return;
2✔
144

145
    qDebug() << "Set universe" << id() << "passthrough to" << enable;
4✔
146

147
    disconnectInputPatch();
4✔
148

149
    if (enable && m_passthroughValues.isNull())
4✔
150
    {
151
        // When passthrough is disabled, we don't release the array, since it's only ~512 B and
152
        // we would have to synchronize with other threads
153

154
        // When enabling passthrough, make sure the array is allocated BEFORE m_passthrough is set to
155
        // true. That way we only have to check for m_passthrough, and do not need to check
156
        // m_passthroughValues.isNull()
157
        m_passthroughValues.reset(new QByteArray(UNIVERSE_SIZE, char(0)));
4✔
158
    }
159

160
    m_passthrough = enable;
4✔
161

162
    connectInputPatch();
4✔
163

164
    emit passthroughChanged();
4✔
165
}
166

167
bool Universe::passthrough() const
12✔
168
{
169
    return m_passthrough;
12✔
170
}
171

172
void Universe::setMonitor(bool enable)
1✔
173
{
174
    m_monitor = enable;
1✔
175
}
1✔
176

177
bool Universe::monitor() const
1✔
178
{
179
    return m_monitor;
1✔
180
}
181

182
void Universe::slotGMValueChanged()
10,032✔
183
{
184
    {
185
        for (int i = 0; i < m_intensityChannels.size(); ++i)
5,130,070✔
186
        {
187
            int channel = m_intensityChannels.at(i);
5,120,040✔
188
            updatePostGMValue(channel);
5,120,040✔
189
        }
190
    }
191

192
    if (m_grandMaster->channelMode() == GrandMaster::AllChannels)
10,032✔
193
    {
194
        for (int i = 0; i < m_nonIntensityChannels.size(); ++i)
32✔
195
        {
196
            int channel = m_nonIntensityChannels.at(i);
15✔
197
            updatePostGMValue(channel);
15✔
198
        }
199
    }
200
}
10,032✔
201

202
/************************************************************************
203
 * Faders
204
 ************************************************************************/
205

206
QSharedPointer<GenericFader> Universe::requestFader(Universe::FaderPriority priority)
147✔
207
{
208
    int insertPos = 0;
147✔
209
    QSharedPointer<GenericFader> fader = QSharedPointer<GenericFader>(new GenericFader());
147✔
210
    fader->setPriority(priority);
147✔
211

212
    if (m_faders.isEmpty())
147✔
213
    {
214
        m_faders.append(fader);
20✔
215
    }
216
    else
217
    {
218
        for (int i = m_faders.count() - 1; i >= 0; i--)
127✔
219
        {
220
            QSharedPointer<GenericFader> f = m_faders.at(i);
127✔
221
            if (!f.isNull() && f->priority() <= fader->priority())
127✔
222
            {
223
                insertPos = i + 1;
127✔
224
                break;
127✔
225
            }
226
        }
227

228
        m_faders.insert(insertPos, fader);
127✔
229
    }
230

231
    qDebug() << "Generic fader with priority" <<  fader->priority() << "registered at pos" << insertPos << ", count" << m_faders.count();
147✔
232

233
    return fader;
147✔
234
}
235

236
void Universe::dismissFader(QSharedPointer<GenericFader> fader)
2✔
237
{
238
    int index = m_faders.indexOf(fader);
2✔
239
    if (index >= 0)
2✔
240
    {
241
        m_faders.takeAt(index);
2✔
242
        fader.clear();
2✔
243
    }
244
}
2✔
245

246
void Universe::requestFaderPriority(QSharedPointer<GenericFader> fader, Universe::FaderPriority priority)
×
247
{
248
    if (m_faders.contains(fader) == false)
×
249
        return;
×
250

251
    int pos = m_faders.indexOf(fader);
×
252
    int newPos = 0;
×
253

254
    for (int i = m_faders.count() - 1; i >= 0; i--)
×
255
    {
256
        QSharedPointer<GenericFader> f = m_faders.at(i);
×
257
        if (!f.isNull() && f->priority() <= priority)
×
258
        {
259
            newPos = i;
×
260
            fader->setPriority(priority);
×
261
            break;
×
262
        }
263
    }
264

265
    if (newPos != pos)
×
266
    {
267
        m_faders.move(pos, newPos);
×
268
        qDebug() << "Generic fader moved from" << pos << "to" << m_faders.indexOf(fader) << ". Count:" << m_faders.count();
×
269
    }
270
}
271

272
QList<QSharedPointer<GenericFader> > Universe::faders()
×
273
{
274
    return m_faders;
×
275
}
276

277
void Universe::setFaderPause(quint32 functionID, bool enable)
8✔
278
{
279
    QMutableListIterator<QSharedPointer<GenericFader> > it(m_faders);
8✔
280
    while (it.hasNext())
17✔
281
    {
282
        QSharedPointer<GenericFader> fader = it.next();
9✔
283
        if (fader.isNull() || fader->parentFunctionID() != functionID)
9✔
284
            continue;
6✔
285

286
        fader->setPaused(enable);
3✔
287
    }
288
}
8✔
289

290
void Universe::tick()
688✔
291
{
292
    m_semaphore.release(1);
688✔
293
}
688✔
294

295
void Universe::processFaders()
264,827✔
296
{
297
    flushInput();
264,827✔
298
    zeroIntensityChannels();
264,827✔
299
    zeroRelativeValues();
264,827✔
300

301
    QMutableListIterator<QSharedPointer<GenericFader> > it(m_faders);
264,827✔
302
    while (it.hasNext())
1,191,900✔
303
    {
304
        QSharedPointer<GenericFader> fader = it.next();
927,070✔
305
        if (fader.isNull())
927,070✔
306
            continue;
×
307

308
        // destroy a fader if it's been requested
309
        // and it's not fading out
310
        if (fader->deleteRequested() && !fader->isFadingOut())
927,070✔
311
        {
312
            fader->removeAll();
2✔
313
            it.remove();
2✔
314
            fader.clear();
2✔
315
            continue;
2✔
316
        }
317

318
        if (fader->isEnabled() == false)
927,068✔
319
            continue;
×
320

321
        //qDebug() << "Processing fader" << fader->name() << fader->channelsCount();
322
        fader->write(this);
927,068✔
323
    }
324

325
    bool dataChanged = hasChanged();
264,827✔
326
    const QByteArray postGM = m_postGMValues->mid(0, m_usedChannels);
529,654✔
327
    dumpOutput(postGM, dataChanged);
264,827✔
328

329
    if (dataChanged)
264,827✔
330
        emit universeWritten(id(), postGM);
132,938✔
331
}
264,827✔
332

333
void Universe::run()
×
334
{
335
    m_running = true;
×
336
    int timeout = int(MasterTimer::tick()) * 2;
×
337

338
    qDebug() << "Universe thread started" << id();
×
339

NEW
340
    while (m_running)
×
341
    {
342
        if (m_semaphore.tryAcquire(1, timeout) == false)
×
343
        {
344
            //qWarning() << "Semaphore not acquired on universe" << id();
345
            continue;
×
346
        }
347
#if 0
348
        if (m_faders.count())
349
            qDebug() << "<<<<<<<< UNIVERSE TICK - id" << id() << "faders:" << m_faders.count();
350
#endif
351
        processFaders();
×
352
    }
353

354
    qDebug() << "Universe thread stopped" << id();
×
355
}
×
356

357
/************************************************************************
358
 * Values
359
 ************************************************************************/
360

361
void Universe::reset()
8✔
362
{
363
    m_preGMValues->fill(0);
8✔
364
    m_blackoutValues->fill(0);
8✔
365

366
    if (m_passthrough)
8✔
367
    {
368
        (*m_postGMValues) = (*m_passthroughValues);
×
369
    }
370
    else
371
    {
372
        m_postGMValues->fill(0);
8✔
373
    }
374
    zeroRelativeValues();
8✔
375
    m_modifiers.fill(NULL, UNIVERSE_SIZE);
8✔
376
    m_passthrough = false; // not releasing m_passthroughValues, see comment in setPassthrough
8✔
377
}
8✔
378

379
void Universe::reset(int address, int range)
3,099,740✔
380
{
381
    if (address >= UNIVERSE_SIZE)
3,099,740✔
382
        return;
×
383

384
    if (address + range > UNIVERSE_SIZE)
3,099,740✔
385
       range = UNIVERSE_SIZE - address;
1✔
386

387
    memset(m_preGMValues->data() + address, 0, range * sizeof(*m_preGMValues->data()));
3,099,740✔
388
    memset(m_blackoutValues->data() + address, 0, range * sizeof(*m_blackoutValues->data()));
3,099,740✔
389
    memset(m_relativeValues.data() + address, 0, range * sizeof(*m_relativeValues.data()));
3,099,740✔
390
    memcpy(m_postGMValues->data() + address, m_modifiedZeroValues->data() + address, range * sizeof(*m_postGMValues->data()));
3,099,740✔
391

392
    applyPassthroughValues(address, range);
3,099,740✔
393
}
394

395
void Universe::applyPassthroughValues(int address, int range)
3,099,740✔
396
{
397
    if (!m_passthrough)
3,099,740✔
398
        return;
3,099,740✔
399

400
    for (int i = address; i < address + range && i < UNIVERSE_SIZE; i++)
×
401
    {
402
        if (static_cast<uchar>(m_postGMValues->at(i)) < static_cast<uchar>(m_passthroughValues->at(i))) // HTP merge
×
403
        {
404
            (*m_postGMValues)[i] = (*m_passthroughValues)[i];
×
405
        }
406
    }
407
}
408

409
void Universe::zeroIntensityChannels()
284,927✔
410
{
411
    updateIntensityChannelsRanges();
284,927✔
412
    int const* channels = m_intensityChannelsRanges.constData();
284,927✔
413
    for (int i = 0; i < m_intensityChannelsRanges.size(); ++i)
3,384,670✔
414
    {
415
        short channel = channels[i] >> 16;
3,099,740✔
416
        short size = channels[i] & 0xffff;
3,099,740✔
417

418
        reset(channel, size);
3,099,740✔
419
    }
420
}
284,927✔
421

422
QHash<int, uchar> Universe::intensityChannels()
1✔
423
{
424
    QHash <int, uchar> intensityList;
1✔
425
    for (int i = 0; i < m_intensityChannels.size(); ++i)
1✔
426
    {
427
        int channel = m_intensityChannels.at(i);
×
428
        intensityList[channel] = m_preGMValues->at(channel);
×
429
    }
430
    return intensityList;
1✔
431
}
432

433
uchar Universe::postGMValue(int address) const
×
434
{
435
    if (address >= m_postGMValues->size())
×
436
        return 0;
×
437

438
    return uchar(m_postGMValues->at(address));
×
439
}
440

441
const QByteArray* Universe::postGMValues() const
4,601✔
442
{
443
    return m_postGMValues.data();
4,601✔
444
}
445

446
void Universe::zeroRelativeValues()
264,835✔
447
{
448
    memset(m_relativeValues.data(), 0, UNIVERSE_SIZE * sizeof(*m_relativeValues.data()));
264,835✔
449
}
264,835✔
450

451
Universe::BlendMode Universe::stringToBlendMode(QString mode)
5✔
452
{
453
    if (mode == KXMLUniverseNormalBlend)
5✔
454
        return NormalBlend;
1✔
455
    else if (mode == KXMLUniverseMaskBlend)
4✔
456
        return MaskBlend;
1✔
457
    else if (mode == KXMLUniverseAdditiveBlend)
3✔
458
        return AdditiveBlend;
1✔
459
    else if (mode == KXMLUniverseSubtractiveBlend)
2✔
460
        return SubtractiveBlend;
1✔
461

462
    return NormalBlend;
1✔
463
}
464

465
QString Universe::blendModeToString(Universe::BlendMode mode)
7✔
466
{
467
    switch(mode)
7✔
468
    {
469
        default:
1✔
470
        case NormalBlend:
471
            return QString(KXMLUniverseNormalBlend);
1✔
472
        break;
473
        case MaskBlend:
1✔
474
            return QString(KXMLUniverseMaskBlend);
1✔
475
        break;
476
        case AdditiveBlend:
4✔
477
            return QString(KXMLUniverseAdditiveBlend);
4✔
478
        break;
479
        case SubtractiveBlend:
1✔
480
            return QString(KXMLUniverseSubtractiveBlend);
1✔
481
        break;
482
    }
483
}
484

485
const QByteArray Universe::preGMValues() const
1,326,100✔
486
{
487
    return *m_preGMValues;
1,326,100✔
488
}
489

490
uchar Universe::preGMValue(int address) const
17,096,500✔
491
{
492
    if (address >= m_preGMValues->size())
17,096,500✔
493
        return 0U;
×
494

495
    return static_cast<uchar>(m_preGMValues->at(address));
17,096,500✔
496
}
497

498
uchar Universe::applyRelative(int channel, uchar value)
17,095,900✔
499
{
500
    if (m_relativeValues[channel] != 0)
17,095,900✔
501
    {
502
        int val = m_relativeValues[channel] + value;
6✔
503
        return CLAMP(val, 0, (int)UCHAR_MAX);
6✔
504
    }
505

506
    return value;
17,095,900✔
507
}
508

509
uchar Universe::applyGM(int channel, uchar value)
17,089,600✔
510
{
511
    if ((m_grandMaster->channelMode() == GrandMaster::Intensity && m_channelsMask->at(channel) & Intensity) ||
23,935,500✔
512
        (m_grandMaster->channelMode() == GrandMaster::AllChannels))
6,845,950✔
513
    {
514
        if (m_grandMaster->valueMode() == GrandMaster::Limit)
10,244,700✔
515
            value = MIN(value, m_grandMaster->value());
792✔
516
        else
517
            value = char(floor((double(value) * m_grandMaster->fraction()) + 0.5));
10,243,900✔
518
    }
519

520
    return value;
17,089,600✔
521
}
522

523
uchar Universe::applyModifiers(int channel, uchar value)
17,095,900✔
524
{
525
    if (m_modifiers.at(channel) != NULL)
17,095,900✔
526
        return m_modifiers.at(channel)->getValue(value);
×
527

528
    return value;
17,095,900✔
529
}
530

531
uchar Universe::applyPassthrough(int channel, uchar value)
17,095,900✔
532
{
533
    if (m_passthrough)
17,095,900✔
534
    {
535
        const uchar passthroughValue = static_cast<uchar>(m_passthroughValues->at(channel));
×
536
        if (value < passthroughValue) // HTP merge
×
537
        {
538
            return passthroughValue;
×
539
        }
540
    }
541

542
    return value;
17,095,900✔
543
}
544

545
void Universe::updatePostGMValue(int channel)
17,095,900✔
546
{
547
    uchar value = preGMValue(channel);
17,095,900✔
548

549
    value = applyRelative(channel, value);
17,095,900✔
550

551
    if (value != 0)
17,095,900✔
552
        value = applyGM(channel, value);
17,087,000✔
553

554
    value = applyModifiers(channel, value);
17,095,900✔
555
    value = applyPassthrough(channel, value);
17,095,900✔
556

557
    (*m_postGMValues)[channel] = static_cast<char>(value);
17,095,900✔
558
}
17,095,900✔
559

560
/************************************************************************
561
 * Patches
562
 ************************************************************************/
563

564
bool Universe::isPatched()
2✔
565
{
566
    if (m_inputPatch != NULL || m_outputPatchList.count() || m_fbPatch != NULL)
2✔
567
        return true;
1✔
568

569
    return false;
1✔
570
}
571

572
bool Universe::setInputPatch(QLCIOPlugin *plugin,
6✔
573
                             quint32 input, QLCInputProfile *profile)
574
{
575
    qDebug() << "[Universe] setInputPatch - ID:" << m_id << ", plugin:" << ((plugin == NULL)?"None":plugin->name())
12✔
576
             << ", input:" << input << ", profile:" << ((profile == NULL)?"None":profile->name());
6✔
577
    if (m_inputPatch == NULL)
6✔
578
    {
579
        if (plugin == NULL || input == QLCIOPlugin::invalidLine())
5✔
580
            return true;
1✔
581

582
        m_inputPatch = new InputPatch(m_id, this);
4✔
583
        connectInputPatch();
4✔
584
    }
585
    else
586
    {
587
        if (input == QLCIOPlugin::invalidLine())
1✔
588
        {
589
            disconnectInputPatch();
×
590
            delete m_inputPatch;
×
591
            m_inputPatch = NULL;
×
592
            emit inputPatchChanged();
×
593
            return true;
×
594
        }
595
    }
596

597
    if (m_inputPatch != NULL)
5✔
598
    {
599
        bool result = m_inputPatch->set(plugin, input, profile);
5✔
600
        emit inputPatchChanged();
5✔
601
        return result;
5✔
602
    }
603

604
    return true;
×
605
}
606

607
bool Universe::setOutputPatch(QLCIOPlugin *plugin, quint32 output, int index)
19✔
608
{
609
    if (index < 0)
19✔
610
        return false;
×
611

612
    qDebug() << "[Universe] setOutputPatch - ID:" << m_id
19✔
613
             << ", plugin:" << ((plugin == NULL) ? "None" : plugin->name()) << ", output:" << output;
19✔
614

615
    // replace or delete an existing patch
616
    if (index < m_outputPatchList.count())
19✔
617
    {
618
        if (plugin == NULL || output == QLCIOPlugin::invalidLine())
2✔
619
        {
620
            // need to delete an existing patch
621
            OutputPatch *patch = m_outputPatchList.takeAt(index);
2✔
622
            delete patch;
2✔
623
            emit outputPatchesCountChanged();
2✔
624
            return true;
2✔
625
        }
626

627
        OutputPatch *patch = m_outputPatchList.at(index);
×
628
        bool result = patch->set(plugin, output);
×
629
        emit outputPatchChanged();
×
630
        return result;
×
631
    }
632
    else
633
    {
634
        if (plugin == NULL || output == QLCIOPlugin::invalidLine())
17✔
635
            return false;
1✔
636

637
        // add a new patch
638
        OutputPatch *patch = new OutputPatch(m_id, this);
16✔
639
        bool result = patch->set(plugin, output);
16✔
640
        m_outputPatchList.append(patch);
16✔
641
        emit outputPatchesCountChanged();
16✔
642
        return result;
16✔
643
    }
644

645
    return false;
646
}
647

648
bool Universe::setFeedbackPatch(QLCIOPlugin *plugin, quint32 output)
×
649
{
650
    qDebug() << Q_FUNC_INFO << "plugin:" << plugin << "output:" << output;
×
651
    if (m_fbPatch == NULL)
×
652
    {
653
        if (plugin == NULL || output == QLCIOPlugin::invalidLine())
×
654
            return false;
×
655

656
        m_fbPatch = new OutputPatch(m_id, this);
×
657
    }
658
    else
659
    {
660
        if (plugin == NULL || output == QLCIOPlugin::invalidLine())
×
661
        {
662
            delete m_fbPatch;
×
663
            m_fbPatch = NULL;
×
664
            emit hasFeedbacksChanged();
×
665
            return true;
×
666
        }
667
    }
668
    if (m_fbPatch != NULL)
×
669
    {
670
        bool result = m_fbPatch->set(plugin, output);
×
671
        emit hasFeedbacksChanged();
×
672
        return result;
×
673
    }
674

675
    return false;
×
676
}
677

678
bool Universe::hasFeedbacks() const
×
679
{
680
    return m_fbPatch != NULL ? true : false;
×
681
}
682

683
InputPatch *Universe::inputPatch() const
170✔
684
{
685
    return m_inputPatch;
170✔
686
}
687

688
OutputPatch *Universe::outputPatch(int index) const
48✔
689
{
690
    if (index < 0 || index >= m_outputPatchList.count())
48✔
691
        return NULL;
14✔
692

693
    return m_outputPatchList.at(index);
34✔
694
}
695

696
int Universe::outputPatchesCount() const
83✔
697
{
698
    return m_outputPatchList.count();
83✔
699
}
700

701
OutputPatch *Universe::feedbackPatch() const
49✔
702
{
703
    return m_fbPatch;
49✔
704
}
705

706
void Universe::dumpOutput(const QByteArray &data, bool dataChanged)
264,855✔
707
{
708
    if (m_outputPatchList.count() == 0)
264,855✔
709
        return;
264,827✔
710

711
    foreach (OutputPatch *op, m_outputPatchList)
84✔
712
    {
713
        if (m_totalChannelsChanged == true)
28✔
714
            op->setPluginParameter(PLUGIN_UNIVERSECHANNELS, m_totalChannels);
4✔
715

716
        if (op->blackout())
28✔
717
            op->dump(m_id, *m_blackoutValues, dataChanged);
12✔
718
        else
719
            op->dump(m_id, data, dataChanged);
16✔
720
    }
721
    m_totalChannelsChanged = false;
28✔
722
}
723

724
void Universe::flushInput()
264,847✔
725
{
726
    if (m_inputPatch == NULL)
264,847✔
727
        return;
264,842✔
728

729
    m_inputPatch->flush(m_id);
5✔
730
}
731

732
void Universe::slotInputValueChanged(quint32 universe, quint32 channel, uchar value, const QString &key)
×
733
{
734
    if (m_passthrough)
×
735
    {
736
        if (universe == m_id)
×
737
        {
738
            qDebug() << "write" << channel << value;
×
739

740
            if (channel >= UNIVERSE_SIZE)
×
741
                return;
×
742

743
            if (channel >= m_usedChannels)
×
744
                m_usedChannels = channel + 1;
×
745

746
            (*m_passthroughValues)[channel] = value;
×
747

748
            updatePostGMValue(channel);
×
749
        }
750
    }
751
    else
752
        emit inputValueChanged(universe, channel, value, key);
×
753
}
754

755
void Universe::connectInputPatch()
8✔
756
{
757
    if (m_inputPatch == NULL)
8✔
758
        return;
4✔
759

760
    if (!m_passthrough)
4✔
761
        connect(m_inputPatch, SIGNAL(inputValueChanged(quint32,quint32,uchar,const QString&)),
4✔
762
                this, SIGNAL(inputValueChanged(quint32,quint32,uchar,QString)));
763
    else
764
        connect(m_inputPatch, SIGNAL(inputValueChanged(quint32,quint32,uchar,const QString&)),
×
765
                this, SLOT(slotInputValueChanged(quint32,quint32,uchar,const QString&)));
766
}
767

768
void Universe::disconnectInputPatch()
4✔
769
{
770
    if (m_inputPatch == NULL)
4✔
771
        return;
4✔
772

773
    if (!m_passthrough)
×
774
        disconnect(m_inputPatch, SIGNAL(inputValueChanged(quint32,quint32,uchar,const QString&)),
×
775
                this, SIGNAL(inputValueChanged(quint32,quint32,uchar,QString)));
776
    else
777
        disconnect(m_inputPatch, SIGNAL(inputValueChanged(quint32,quint32,uchar,const QString&)),
×
778
                this, SLOT(slotInputValueChanged(quint32,quint32,uchar,const QString&)));
779
}
780

781
/************************************************************************
782
 * Channels capabilities
783
 ************************************************************************/
784

785
void Universe::setChannelCapability(ushort channel, QLCChannel::Group group, ChannelType forcedType)
7,205✔
786
{
787
    if (channel >= (ushort)m_channelsMask->length())
7,205✔
788
        return;
×
789

790
    if (Utils::vectorRemove(m_intensityChannels, channel))
7,205✔
791
        m_intensityChannelsChanged = true;
823✔
792
    Utils::vectorRemove(m_nonIntensityChannels, channel);
7,205✔
793

794
    if (forcedType != Undefined)
7,205✔
795
    {
796
        (*m_channelsMask)[channel] = char(forcedType);
5✔
797
        if ((forcedType & HTP) == HTP)
5✔
798
        {
799
            //qDebug() << "--- Channel" << channel << "forced type HTP";
800
            Utils::vectorSortedAddUnique(m_intensityChannels, channel);
3✔
801
            m_intensityChannelsChanged = true;
3✔
802
            if (group == QLCChannel::Intensity)
3✔
803
            {
804
                //qDebug() << "--- Channel" << channel << "Intensity + HTP";
805
                (*m_channelsMask)[channel] = char(HTP | Intensity);
×
806
            }
807
        }
808
        else if ((forcedType & LTP) == LTP)
2✔
809
        {
810
            //qDebug() << "--- Channel" << channel << "forced type LTP";
811
            Utils::vectorSortedAddUnique(m_nonIntensityChannels, channel);
2✔
812
        }
813
    }
814
    else
815
    {
816
        if (group == QLCChannel::Intensity)
7,200✔
817
        {
818
            //qDebug() << "--- Channel" << channel << "Intensity + HTP";
819
            (*m_channelsMask)[channel] = char(HTP | Intensity);
5,655✔
820
            Utils::vectorSortedAddUnique(m_intensityChannels, channel);
5,655✔
821
            m_intensityChannelsChanged = true;
5,655✔
822
        }
823
        else
824
        {
825
            //qDebug() << "--- Channel" << channel << "LTP";
826
            (*m_channelsMask)[channel] = char(LTP);
1,545✔
827
            Utils::vectorSortedAddUnique(m_nonIntensityChannels, channel);
1,545✔
828
        }
829
    }
830

831
    // qDebug() << Q_FUNC_INFO << "Channel:" << channel << "mask:" << QString::number(m_channelsMask->at(channel), 16);
832
    if (channel >= m_totalChannels)
7,205✔
833
    {
834
        m_totalChannels = channel + 1;
5,619✔
835
        m_totalChannelsChanged = true;
5,619✔
836
    }
837
}
838

839
uchar Universe::channelCapabilities(ushort channel)
517✔
840
{
841
    if (channel >= (ushort)m_channelsMask->length())
517✔
842
        return Undefined;
×
843

844
    return m_channelsMask->at(channel);
517✔
845
}
846

847
void Universe::setChannelDefaultValue(ushort channel, uchar value)
2,556✔
848
{
849
    if (channel >= m_totalChannels)
2,556✔
850
    {
851
        m_totalChannels = channel + 1;
×
852
        m_totalChannelsChanged = true;
×
853
    }
854

855
    if (channel >= m_usedChannels)
2,556✔
856
        m_usedChannels = channel + 1;
970✔
857

858
    (*m_preGMValues)[channel] = value;
2,556✔
859
    updatePostGMValue(channel);
2,556✔
860
}
2,556✔
861

862
void Universe::setChannelModifier(ushort channel, ChannelModifier *modifier)
2,556✔
863
{
864
    if (channel >= (ushort)m_modifiers.count())
2,556✔
865
        return;
×
866

867
    m_modifiers[channel] = modifier;
2,556✔
868

869
    if (modifier != NULL)
2,556✔
870
    {
871
        (*m_modifiedZeroValues)[channel] = modifier->getValue(0);
×
872

873
        if (channel >= m_totalChannels)
×
874
        {
875
            m_totalChannels = channel + 1;
×
876
            m_totalChannelsChanged = true;
×
877
        }
878

879
        if (channel >= m_usedChannels)
×
880
            m_usedChannels = channel + 1;
×
881
    }
882

883
    updatePostGMValue(channel);
2,556✔
884
}
885

886
ChannelModifier *Universe::channelModifier(ushort channel)
×
887
{
888
    if (channel >= (ushort)m_modifiers.count())
×
889
        return NULL;
×
890

891
    return m_modifiers.at(channel);
×
892
}
893

894
void Universe::updateIntensityChannelsRanges()
284,927✔
895
{
896
    if (!m_intensityChannelsChanged)
284,927✔
897
        return;
284,890✔
898

899
    m_intensityChannelsChanged = false;
37✔
900

901
    m_intensityChannelsRanges.clear();
37✔
902
    short currentPos = -1;
37✔
903
    short currentSize = 0;
37✔
904

905
    for (int i = 0; i < m_intensityChannels.size(); ++i)
1,797✔
906
    {
907
        int channel = m_intensityChannels.at(i);
1,760✔
908
        if (currentPos + currentSize == channel)
1,760✔
909
            ++currentSize;
1,179✔
910
        else
911
        {
912
            if (currentPos != -1)
581✔
913
                m_intensityChannelsRanges.append((currentPos << 16) | currentSize);
544✔
914
            currentPos = channel;
581✔
915
            currentSize = 1;
581✔
916
        }
917
    }
918
    if (currentPos != -1)
37✔
919
        m_intensityChannelsRanges.append((currentPos << 16) | currentSize);
37✔
920

921
    qDebug() << Q_FUNC_INFO << ":" << m_intensityChannelsRanges.size() << "ranges";
37✔
922
}
923

924
/****************************************************************************
925
 * Writing
926
 ****************************************************************************/
927

928
bool Universe::write(int channel, uchar value, bool forceLTP)
11,970,700✔
929
{
930
    Q_ASSERT(channel < UNIVERSE_SIZE);
11,970,700✔
931

932
    //qDebug() << "Universe write channel" << channel << ", value:" << value;
933

934
    if (channel >= m_usedChannels)
11,970,700✔
935
        m_usedChannels = channel + 1;
9,371✔
936

937
    if ((m_channelsMask->at(channel) & HTP) == false)
11,970,700✔
938
        (*m_blackoutValues)[channel] = char(value);
6,847,910✔
939

940
    if (forceLTP == false && (m_channelsMask->at(channel) & HTP) && value < (uchar)m_preGMValues->at(channel))
11,970,700✔
941
    {
942
        qDebug() << "[Universe] HTP check not passed" << channel << value;
×
943
        return false;
×
944
    }
945

946
    (*m_preGMValues)[channel] = char(value);
11,970,700✔
947

948
    updatePostGMValue(channel);
11,970,700✔
949

950
    return true;
11,970,700✔
951
}
952

953
bool Universe::writeRelative(int channel, uchar value)
6✔
954
{
955
    Q_ASSERT(channel < UNIVERSE_SIZE);
6✔
956

957
    //qDebug() << "Write relative channel" << channel << value;
958

959
    if (channel >= m_usedChannels)
6✔
960
        m_usedChannels = channel + 1;
1✔
961

962
    if (value == RELATIVE_ZERO)
6✔
963
        return true;
1✔
964

965
    m_relativeValues[channel] += value - RELATIVE_ZERO;
5✔
966

967
    updatePostGMValue(channel);
5✔
968

969
    return true;
5✔
970
}
971

972
bool Universe::writeBlended(int channel, uchar value, Universe::BlendMode blend)
1,722,370✔
973
{
974
    if (channel >= m_usedChannels)
1,722,370✔
975
        m_usedChannels = channel + 1;
1✔
976

977
    switch (blend)
1,722,370✔
978
    {
979
        case NormalBlend:
1,722,360✔
980
            return write(channel, value);
1,722,360✔
981

982
        case MaskBlend:
2✔
983
        {
984
            if (value)
2✔
985
            {
986
                float currValue = (float)uchar(m_preGMValues->at(channel));
2✔
987
                if (currValue)
2✔
988
                    value = currValue * ((float)value / 255.0);
1✔
989
                else
990
                    value = 0;
1✔
991
            }
992
            (*m_preGMValues)[channel] = char(value);
2✔
993
        }
994
        break;
2✔
995
        case AdditiveBlend:
1✔
996
        {
997
            uchar currVal = uchar(m_preGMValues->at(channel));
1✔
998
            //qDebug() << "Universe write additive channel" << channel << ", value:" << currVal << "+" << value;
999
            value = qMin(int(currVal) + value, 255);
1✔
1000
            (*m_preGMValues)[channel] = char(value);
1✔
1001
        }
1002
        break;
1✔
1003
        case SubtractiveBlend:
2✔
1004
        {
1005
            uchar currVal = uchar(m_preGMValues->at(channel));
2✔
1006
            if (value >= currVal)
2✔
1007
                value = 0;
1✔
1008
            else
1009
                value = currVal - value;
1✔
1010
            (*m_preGMValues)[channel] = char(value);
2✔
1011
        }
1012
        break;
2✔
1013
        default:
1✔
1014
            qDebug() << "[Universe] Blend mode not handled. Implement me!" << blend;
1✔
1015
        break;
1✔
1016
    }
1017

1018
    updatePostGMValue(channel);
6✔
1019

1020
    return true;
6✔
1021
}
1022

1023
/*********************************************************************
1024
 * Load & Save
1025
 *********************************************************************/
1026

1027
bool Universe::loadXML(QXmlStreamReader &root, int index, InputOutputMap *ioMap)
5✔
1028
{
1029
    if (root.name() != KXMLQLCUniverse)
5✔
1030
    {
1031
        qWarning() << Q_FUNC_INFO << "Universe node not found";
1✔
1032
        return false;
1✔
1033
    }
1034

1035
    int outputIndex = 0;
4✔
1036

1037
    QXmlStreamAttributes attrs = root.attributes();
4✔
1038

1039
    if (attrs.hasAttribute(KXMLQLCUniverseName))
4✔
1040
        setName(attrs.value(KXMLQLCUniverseName).toString());
4✔
1041

1042
    if (attrs.hasAttribute(KXMLQLCUniversePassthrough))
4✔
1043
    {
1044
        if (attrs.value(KXMLQLCUniversePassthrough).toString() == KXMLQLCTrue ||
8✔
1045
            attrs.value(KXMLQLCUniversePassthrough).toString() == "1")
5✔
1046
            setPassthrough(true);
2✔
1047
        else
1048
            setPassthrough(false);
1✔
1049
    }
1050
    else
1051
    {
1052
        setPassthrough(false);
1✔
1053
    }
1054

1055
    while (root.readNextStartElement())
4✔
1056
    {
1057
        QXmlStreamAttributes pAttrs = root.attributes();
×
1058

1059
        if (root.name() == KXMLQLCUniverseInputPatch)
×
1060
        {
1061
            QString plugin = KInputNone;
×
1062
            quint32 inputLine = QLCIOPlugin::invalidLine();
×
1063
            QString inputUID;
×
1064
            QString profile = KInputNone;
×
1065

1066
            if (pAttrs.hasAttribute(KXMLQLCUniversePlugin))
×
1067
                plugin = pAttrs.value(KXMLQLCUniversePlugin).toString();
×
1068
            if (pAttrs.hasAttribute(KXMLQLCUniverseLineUID))
×
1069
                inputUID = pAttrs.value(KXMLQLCUniverseLineUID).toString();
×
1070
            if (pAttrs.hasAttribute(KXMLQLCUniverseLine))
×
1071
                inputLine = pAttrs.value(KXMLQLCUniverseLine).toString().toUInt();
×
1072
            if (pAttrs.hasAttribute(KXMLQLCUniverseProfileName))
×
1073
                profile = pAttrs.value(KXMLQLCUniverseProfileName).toString();
×
1074

1075
            // apply the parameters just loaded
1076
            ioMap->setInputPatch(index, plugin, inputUID, inputLine, profile);
×
1077

1078
            QXmlStreamReader::TokenType tType = root.readNext();
×
1079
            if (tType == QXmlStreamReader::Characters)
×
1080
                tType = root.readNext();
×
1081

1082
            // check if there is a PluginParameters tag defined
1083
            if (tType == QXmlStreamReader::StartElement)
×
1084
            {
1085
                if (root.name() == KXMLQLCUniversePluginParameters)
×
1086
                    loadXMLPluginParameters(root, InputPatchTag, 0);
×
1087
                root.skipCurrentElement();
×
1088
            }
1089
        }
1090
        else if (root.name() == KXMLQLCUniverseOutputPatch)
×
1091
        {
1092
            QString plugin = KOutputNone;
×
1093
            QString outputUID;
×
1094
            quint32 outputLine = QLCIOPlugin::invalidLine();
×
1095

1096
            if (pAttrs.hasAttribute(KXMLQLCUniversePlugin))
×
1097
                plugin = pAttrs.value(KXMLQLCUniversePlugin).toString();
×
1098
            if (pAttrs.hasAttribute(KXMLQLCUniverseLineUID))
×
1099
                outputUID = pAttrs.value(KXMLQLCUniverseLineUID).toString();
×
1100
            if (pAttrs.hasAttribute(KXMLQLCUniverseLine))
×
1101
                outputLine = pAttrs.value(KXMLQLCUniverseLine).toString().toUInt();
×
1102

1103
            // apply the parameters just loaded
1104
            ioMap->setOutputPatch(index, plugin, outputUID, outputLine, false, outputIndex);
×
1105

1106
            QXmlStreamReader::TokenType tType = root.readNext();
×
1107
            if (tType == QXmlStreamReader::Characters)
×
1108
                tType = root.readNext();
×
1109

1110
            // check if there is a PluginParameters tag defined
1111
            if (tType == QXmlStreamReader::StartElement)
×
1112
            {
1113
                if (root.name() == KXMLQLCUniversePluginParameters)
×
1114
                    loadXMLPluginParameters(root, OutputPatchTag, outputIndex);
×
1115
                root.skipCurrentElement();
×
1116
            }
1117

1118
            outputIndex++;
×
1119
        }
1120
        else if (root.name() == KXMLQLCUniverseFeedbackPatch)
×
1121
        {
1122
            QString plugin = KOutputNone;
×
1123
            QString outputUID;
×
1124
            quint32 output = QLCIOPlugin::invalidLine();
×
1125

1126
            if (pAttrs.hasAttribute(KXMLQLCUniversePlugin))
×
1127
                plugin = pAttrs.value(KXMLQLCUniversePlugin).toString();
×
1128
            if (pAttrs.hasAttribute(KXMLQLCUniverseLineUID))
×
1129
                outputUID = pAttrs.value(KXMLQLCUniverseLineUID).toString();
×
1130
            if (pAttrs.hasAttribute(KXMLQLCUniverseLine))
×
1131
                output = pAttrs.value(KXMLQLCUniverseLine).toString().toUInt();
×
1132

1133
            // apply the parameters just loaded
1134
            ioMap->setOutputPatch(index, plugin, outputUID, output, true);
×
1135

1136
            QXmlStreamReader::TokenType tType = root.readNext();
×
1137
            if (tType == QXmlStreamReader::Characters)
×
1138
                tType = root.readNext();
×
1139

1140
            // check if there is a PluginParameters tag defined
1141
            if (tType == QXmlStreamReader::StartElement)
×
1142
            {
1143
                if (root.name() == KXMLQLCUniversePluginParameters)
×
1144
                    loadXMLPluginParameters(root, FeedbackPatchTag, 0);
×
1145
                root.skipCurrentElement();
×
1146
            }
1147
        }
1148
        else
1149
        {
1150
            qWarning() << Q_FUNC_INFO << "Unknown Universe tag:" << root.name();
×
1151
            root.skipCurrentElement();
×
1152
        }
1153
    }
1154

1155
    return true;
4✔
1156
}
1157

1158
bool Universe::loadXMLPluginParameters(QXmlStreamReader &root, PatchTagType currentTag, int patchIndex)
×
1159
{
1160
    if (root.name() != KXMLQLCUniversePluginParameters)
×
1161
    {
1162
        qWarning() << Q_FUNC_INFO << "PluginParameters node not found";
×
1163
        return false;
×
1164
    }
1165

1166
    QXmlStreamAttributes pluginAttrs = root.attributes();
×
1167
    for (int i = 0; i < pluginAttrs.count(); i++)
×
1168
    {
1169
        QXmlStreamAttribute attr = pluginAttrs.at(i);
×
1170
        if (currentTag == InputPatchTag)
×
1171
        {
1172
            InputPatch *ip = inputPatch();
×
1173
            if (ip != NULL)
×
1174
                ip->setPluginParameter(attr.name().toString(), attr.value().toString());
×
1175
        }
1176
        else if (currentTag == OutputPatchTag)
×
1177
        {
1178
            OutputPatch *op = outputPatch(patchIndex);
×
1179
            if (op != NULL)
×
1180
                op->setPluginParameter(attr.name().toString(), attr.value().toString());
×
1181
        }
1182
        else if (currentTag == FeedbackPatchTag)
×
1183
        {
1184
            OutputPatch *fbp = feedbackPatch();
×
1185
            if (fbp != NULL)
×
1186
                fbp->setPluginParameter(attr.name().toString(), attr.value().toString());
×
1187
        }
1188
    }
1189
    root.skipCurrentElement();
×
1190

1191
    return true;
×
1192
}
1193

1194
bool Universe::saveXML(QXmlStreamWriter *doc) const
6✔
1195
{
1196
    Q_ASSERT(doc != NULL);
6✔
1197

1198
    doc->writeStartElement(KXMLQLCUniverse);
6✔
1199
    doc->writeAttribute(KXMLQLCUniverseName, name());
6✔
1200
    doc->writeAttribute(KXMLQLCUniverseID, QString::number(id()));
6✔
1201

1202
    if (passthrough() == true)
6✔
1203
        doc->writeAttribute(KXMLQLCUniversePassthrough, KXMLQLCTrue);
1✔
1204

1205
    if (inputPatch() != NULL)
6✔
1206
    {
1207
        savePatchXML(doc, KXMLQLCUniverseInputPatch, inputPatch()->pluginName(), inputPatch()->inputName(),
×
1208
            inputPatch()->input(), inputPatch()->profileName(), inputPatch()->getPluginParameters());
×
1209
    }
1210
    foreach (OutputPatch *op, m_outputPatchList)
6✔
1211
    {
1212
        savePatchXML(doc, KXMLQLCUniverseOutputPatch, op->pluginName(), op->outputName(),
×
1213
            op->output(), "", op->getPluginParameters());
×
1214
    }
1215
    if (feedbackPatch() != NULL)
6✔
1216
    {
1217
        savePatchXML(doc, KXMLQLCUniverseFeedbackPatch, feedbackPatch()->pluginName(), feedbackPatch()->outputName(),
×
1218
            feedbackPatch()->output(), "", feedbackPatch()->getPluginParameters());
×
1219
    }
1220

1221
    /* End the <Universe> tag */
1222
    doc->writeEndElement();
6✔
1223

1224
    return true;
6✔
1225
}
1226

1227
void Universe::savePatchXML(
×
1228
    QXmlStreamWriter *doc,
1229
    const QString &tag,
1230
    const QString &pluginName,
1231
    const QString &lineName,
1232
    quint32 line,
1233
    QString profileName,
1234
    QMap<QString, QVariant> parameters) const
1235
{
1236
    // sanity check: don't save invalid data
1237
    if (pluginName.isEmpty() || pluginName == KInputNone || line == QLCIOPlugin::invalidLine())
×
1238
        return;
×
1239

1240
    doc->writeStartElement(tag);
×
1241
    doc->writeAttribute(KXMLQLCUniversePlugin, pluginName);
×
1242
    doc->writeAttribute(KXMLQLCUniverseLineUID, lineName);
×
1243
    doc->writeAttribute(KXMLQLCUniverseLine, QString::number(line));
×
1244
    if (!profileName.isEmpty() && profileName != KInputNone)
×
1245
        doc->writeAttribute(KXMLQLCUniverseProfileName, profileName);
×
1246

1247
    savePluginParametersXML(doc, parameters);
×
1248
    doc->writeEndElement();
×
1249
}
1250

1251
bool Universe::savePluginParametersXML(QXmlStreamWriter *doc,
×
1252
                                       QMap<QString, QVariant> parameters) const
1253
{
1254
    Q_ASSERT(doc != NULL);
×
1255

1256
    if (parameters.isEmpty())
×
1257
        return false;
×
1258

1259
    doc->writeStartElement(KXMLQLCUniversePluginParameters);
×
1260
    QMapIterator<QString, QVariant> it(parameters);
×
NEW
1261
    while (it.hasNext())
×
1262
    {
1263
        it.next();
×
1264
        QString pName = it.key();
×
1265
        QVariant pValue = it.value();
×
1266
        doc->writeAttribute(pName, pValue.toString());
×
1267
    }
1268
    doc->writeEndElement();
×
1269

1270
    return true;
×
1271
}
1272

STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc