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

wirenboard / wb-mqtt-serial / 1

20 Oct 2026 04:57AM UTC coverage: 86.817% (+14.0%) from 72.836%
1

Pull #1281

github

fab311
KraPete
Fix device model fallback in device/LoadConfig RPC

The short model register was never read because the extended register
error was converted to TRPCException. The RPC error now says that
the device model could not be read.
Pull Request #1281: Fix device model fallback in device/LoadConfig RPC

31180 of 80922 branches covered (38.53%)

74 of 76 new or added lines in 2 files covered. (97.37%)

1379 existing lines in 84 files now uncovered.

22464 of 25875 relevant lines covered (86.82%)

5205.91 hits per line

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

80.0
/src/rpc/rpc_device_handler.cpp
1
#include "rpc_device_handler.h"
2
#include "rpc_device_load_config_task.h"
3
#include "rpc_device_load_task.h"
4
#include "rpc_device_probe_task.h"
5
#include "rpc_device_set_task.h"
6
#include "rpc_helpers.h"
7

8
#define LOG(logger) ::logger.Log() << "[RPC] "
9

10
namespace
11
{
12
    bool IsSupportedByFw(const Json::Value& item, const std::string& fwVersion)
398 ✔
13
    {
14
        if (fwVersion.empty()) {
398 ✔
15
            return true;
316 ✔
16
        }
17
        std::string fw = item["fw"].asString();
82 !
18
        return fw.empty() || util::CompareVersionStrings(fw, fwVersion) <= 0;
82 !
19
    }
82 ✔
20

21
    TRPCRegister CreateRPCRegister(const TDeviceProtocolParams& protocolParams,
356 ✔
22
                                   PSerialDevice device,
23
                                   const std::string& id,
24
                                   const Json::Value& item,
25
                                   bool checkUnsupported)
26
    {
27
        auto config = LoadRegisterConfig(item,
28
                                         *protocolParams.protocol->GetRegTypes(),
356 !
29
                                         std::string(),
712 ✔
30
                                         *protocolParams.factory,
356 ✔
31
                                         protocolParams.factory->GetRegisterAddressFactory().GetBaseRegisterAddress(),
356 !
32
                                         0);
356 !
33
        TRPCRegister reg = {id,
34
                            item["condition"].asString(),
712 ✔
35
                            std::make_shared<TRegister>(device, config.RegisterConfig),
36
                            checkUnsupported};
356 !
37
        reg.Register->SetAvailable(TRegisterAvailability::AVAILABLE);
356 !
38

39
        // this code checks enums and ranges only for 16-bit register unsupported value 0xFFFE
40
        // it must be modified to check larger registers like 24, 32 or 64-bits
41
        if (reg.CheckUnsupported) {
356 ✔
42
            int unsupportedValue =
43
                config.RegisterConfig->Format == S16 ? static_cast<int16_t>(0xFFFE) : static_cast<uint16_t>(0xFFFE);
170 ✔
44
            if (item.isMember("enum")) {
170 !
45
                for (const auto& value: item["enum"]) {
90 !
46
                    try {
47
                        if (std::stoi(value.asString(), 0, 0) == unsupportedValue) {
64 !
48
                            reg.CheckUnsupported = false;
6 ✔
49
                            break;
6 ✔
50
                        }
UNCOV
51
                    } catch (const std::logic_error&) {
×
52
                    }
×
53
                }
54
            } else {
55
                if (item["min"].asInt() <= unsupportedValue && item["max"].asInt() >= unsupportedValue) {
138 !
56
                    reg.CheckUnsupported = false;
2 ✔
57
                }
58
            }
59
        }
60
        return reg;
356 ✔
61
    }
356 ✔
62
} // namespace
63

64
#ifndef __EMSCRIPTEN__
65
TRPCDeviceHelper::TRPCDeviceHelper(const Json::Value& request,
6 ✔
66
                                   const TSerialDeviceFactory& deviceFactory,
67
                                   PTemplateMap templates,
68
                                   PSerialDevice deviceFromConfig)
6 ✔
69
{
70
    if (deviceFromConfig == nullptr) {
6 !
71
        DeviceTemplate = templates->GetTemplate(request["device_type"].asString());
×
72
        auto protocolName = DeviceTemplate->GetProtocol();
×
UNCOV
73
        if (protocolName == "modbus" && request["modbus_mode"].asString() == "TCP") {
×
UNCOV
74
            protocolName += "-tcp";
×
75
        }
UNCOV
76
        ProtocolParams = deviceFactory.GetProtocolParams(protocolName);
×
77
        auto config = std::make_shared<TDeviceConfig>("RPC Device", request["slave_id"].asString(), protocolName);
×
UNCOV
78
        bool isWbDevice = !DeviceTemplate->GetHardware().empty() ||
×
79
                          DeviceTemplate->GetTemplate()["enable_wb_continuous_read"].asBool();
×
80
        LoadCommonDeviceParameters(*config, DeviceTemplate->GetTemplate(), isWbDevice);
×
UNCOV
81
        Device = ProtocolParams.factory->CreateDevice(DeviceTemplate->GetTemplate(), config, ProtocolParams.protocol);
×
UNCOV
82
        Device->SetWbDevice(isWbDevice);
×
83
    } else {
×
84
        Device = deviceFromConfig;
6 ✔
85
        DeviceTemplate = templates->GetTemplate(Device->DeviceConfig()->DeviceType);
6 !
86
        ProtocolParams = deviceFactory.GetProtocolParams(Device->Protocol()->GetName());
6 !
87
        DeviceFromConfig = true;
6 ✔
88
    }
89
    if (DeviceTemplate->WithSubdevices()) {
6 !
90
        throw TRPCException("Device \"" + DeviceTemplate->Type + "\" is not supported by this RPC",
×
UNCOV
91
                            TRPCResultCode::RPC_WRONG_PARAM_VALUE);
×
92
    }
93
}
6 ✔
94
#endif
95

96
TRPCDeviceRequest::TRPCDeviceRequest(const TDeviceProtocolParams& protocolParams,
210 ✔
97
                                     PSerialDevice device,
98
                                     PDeviceTemplate deviceTemplate,
99
                                     bool deviceFromConfig)
210 ✔
100
    : ProtocolParams(protocolParams),
210 ✔
101
      Device(device),
210 ✔
102
      DeviceTemplate(deviceTemplate),
210 ✔
103
      DeviceFromConfig(deviceFromConfig)
210 !
104
{
105
    Json::Value responseTimeout = DeviceTemplate->GetTemplate()["response_timeout_ms"];
210 !
106
    if (responseTimeout.isInt()) {
210 !
107
        ResponseTimeout = std::chrono::milliseconds(responseTimeout.asInt());
×
108
    }
109

110
    Json::Value frameTimeout = DeviceTemplate->GetTemplate()["frame_timeout_ms"];
210 !
111
    if (frameTimeout.isInt()) {
210 !
UNCOV
112
        FrameTimeout = std::chrono::milliseconds(frameTimeout.asInt());
×
113
    }
114
}
210 ✔
115

116
void TRPCDeviceRequest::ParseSettings(const Json::Value& request,
4 ✔
117
                                      WBMQTT::TMqttRpcServer::TResultCallback onResult,
118
                                      WBMQTT::TMqttRpcServer::TErrorCallback onError)
119
{
120
    SerialPortSettings = ParseRPCSerialPortSettings(request);
4 !
121
    WBMQTT::JSON::Get(request, "response_timeout", ResponseTimeout);
8 !
122
    WBMQTT::JSON::Get(request, "frame_timeout", FrameTimeout);
8 !
123
    WBMQTT::JSON::Get(request, "total_timeout", TotalTimeout);
4 !
124
    OnResult = onResult;
4 ✔
125
    OnError = onError;
4 ✔
126
}
4 ✔
127

128
#ifndef __EMSCRIPTEN__
129
TRPCDeviceHandler::TRPCDeviceHandler(const std::string& configFileName,
106 ✔
130
                                     const std::string& requestDeviceLoadConfigSchemaFilePath,
131
                                     const std::string& requestDeviceLoadSchemaFilePath,
132
                                     const std::string& requestDeviceSetSchemaFilePath,
133
                                     const std::string& requestDeviceProbeSchemaFilePath,
134
                                     const std::string& requestDeviceSetPollSchemaFilePath,
135
                                     const TSerialDeviceFactory& deviceFactory,
136
                                     PTemplateMap templates,
137
                                     TSerialClientTaskRunner& serialClientTaskRunner,
138
                                     WBMQTT::PMqttRpcServer rpcServer)
106 ✔
139
    : ConfigFileName(configFileName),
106 ✔
140
      DeviceFactory(deviceFactory),
106 ✔
141
      RequestDeviceLoadConfigSchema(LoadRPCRequestSchema(requestDeviceLoadConfigSchemaFilePath, "device/LoadConfig")),
212 !
142
      RequestDeviceLoadSchema(LoadRPCRequestSchema(requestDeviceLoadSchemaFilePath, "device/Load")),
212 !
143
      RequestDeviceSetSchema(LoadRPCRequestSchema(requestDeviceSetSchemaFilePath, "device/Set")),
212 !
144
      RequestDeviceProbeSchema(LoadRPCRequestSchema(requestDeviceProbeSchemaFilePath, "device/Probe")),
212 !
145
      RequestDeviceSetPollSchema(LoadRPCRequestSchema(requestDeviceSetPollSchemaFilePath, "device/SetPoll")),
106 !
146
      Templates(templates),
106 ✔
147
      SerialClientTaskRunner(serialClientTaskRunner)
106 ✔
148
{
149
    rpcServer->RegisterAsyncMethod("device",
530 !
150
                                   "LoadConfig",
UNCOV
151
                                   std::bind(&TRPCDeviceHandler::LoadConfig,
×
152
                                             this,
212 !
153
                                             std::placeholders::_1,
154
                                             std::placeholders::_2,
155
                                             std::placeholders::_3));
156
    rpcServer->RegisterAsyncMethod("device",
530 !
157
                                   "Load",
UNCOV
158
                                   std::bind(&TRPCDeviceHandler::Load, //
×
159
                                             this,
212 !
160
                                             std::placeholders::_1,
161
                                             std::placeholders::_2,
162
                                             std::placeholders::_3));
163
    rpcServer->RegisterAsyncMethod("device",
530 !
164
                                   "Set",
UNCOV
165
                                   std::bind(&TRPCDeviceHandler::Set, //
×
166
                                             this,
212 !
167
                                             std::placeholders::_1,
168
                                             std::placeholders::_2,
169
                                             std::placeholders::_3));
170
    rpcServer->RegisterAsyncMethod("device",
530 !
171
                                   "Probe",
UNCOV
172
                                   std::bind(&TRPCDeviceHandler::Probe,
×
173
                                             this,
212 !
174
                                             std::placeholders::_1,
175
                                             std::placeholders::_2,
176
                                             std::placeholders::_3));
177

178
    rpcServer->RegisterMethod("device", "SetPoll", std::bind(&TRPCDeviceHandler::SetPoll, this, std::placeholders::_1));
424 !
179
}
106 ✔
180

181
void TRPCDeviceHandler::LoadConfig(const Json::Value& request,
10 ✔
182
                                   WBMQTT::TMqttRpcServer::TResultCallback onResult,
183
                                   WBMQTT::TMqttRpcServer::TErrorCallback onError)
184
{
185
    ValidateRPCRequest(request, RequestDeviceLoadConfigSchema);
10 ✔
186
    try {
187
        SerialClientTaskRunner.RunTask(request, [&](PSerialDevice deviceFromConfig, PDeviceParametersCache cache) {
16 !
188
            auto helper = TRPCDeviceHelper(request, DeviceFactory, Templates, deviceFromConfig);
6 !
189
            // Without "hw" the model is not checked, the settings may be read from another device
190
            if (helper.DeviceTemplate->GetHardware().empty()) {
6 !
191
                throw TRPCException("Device \"" + helper.DeviceTemplate->Type +
4 !
192
                                        "\" without \"hw\" in the template is not supported by this RPC",
2 !
193
                                    TRPCResultCode::RPC_WRONG_PARAM_VALUE);
6 !
194
            }
195
            auto rpcRequest = ParseRPCDeviceLoadConfigRequest(request,
12 !
196
                                                              helper.ProtocolParams,
197
                                                              helper.Device,
198
                                                              helper.DeviceTemplate,
199
                                                              helper.DeviceFromConfig,
200
                                                              ConfigFileName,
201
                                                              cache,
202
                                                              onResult,
203
                                                              onError);
12 !
204
            return std::make_shared<TRPCDeviceLoadConfigSerialClientTask>(rpcRequest);
8 !
205
        });
6 ✔
206
    } catch (const TRPCException& e) {
6 !
207
        ProcessException(e, onError);
6 !
208
    }
6 ✔
209
}
10 ✔
210

211
void TRPCDeviceHandler::Load(const Json::Value& request,
2 ✔
212
                             WBMQTT::TMqttRpcServer::TResultCallback onResult,
213
                             WBMQTT::TMqttRpcServer::TErrorCallback onError)
214
{
215
    ValidateRPCRequest(request, RequestDeviceLoadSchema);
2 ✔
216
    try {
217
        SerialClientTaskRunner.RunTask(request, [&](PSerialDevice deviceFromConfig, PDeviceParametersCache) {
4 !
UNCOV
218
            auto helper = TRPCDeviceHelper(request, DeviceFactory, Templates, deviceFromConfig);
×
UNCOV
219
            auto rpcRequest = ParseRPCDeviceLoadRequest(request,
×
220
                                                        helper.ProtocolParams,
221
                                                        helper.Device,
222
                                                        helper.DeviceTemplate,
223
                                                        helper.DeviceFromConfig,
224
                                                        onResult,
UNCOV
225
                                                        onError);
×
UNCOV
226
            return std::make_shared<TRPCDeviceLoadSerialClientTask>(rpcRequest);
×
UNCOV
227
        });
×
228
    } catch (const TRPCException& e) {
2 !
229
        ProcessException(e, onError);
2 !
230
    }
2 ✔
231
}
2 ✔
232

233
void TRPCDeviceHandler::Set(const Json::Value& request,
2 ✔
234
                            WBMQTT::TMqttRpcServer::TResultCallback onResult,
235
                            WBMQTT::TMqttRpcServer::TErrorCallback onError)
236
{
237
    ValidateRPCRequest(request, RequestDeviceSetSchema);
2 ✔
238
    try {
239
        SerialClientTaskRunner.RunTask(request, [&](PSerialDevice deviceFromConfig, PDeviceParametersCache) {
4 !
UNCOV
240
            auto helper = TRPCDeviceHelper(request, DeviceFactory, Templates, deviceFromConfig);
×
UNCOV
241
            auto rpcRequest = ParseRPCDeviceSetRequest(request,
×
242
                                                       helper.ProtocolParams,
243
                                                       helper.Device,
244
                                                       helper.DeviceTemplate,
245
                                                       helper.DeviceFromConfig,
246
                                                       onResult,
UNCOV
247
                                                       onError);
×
UNCOV
248
            return std::make_shared<TRPCDeviceSetSerialClientTask>(rpcRequest);
×
UNCOV
249
        });
×
250
    } catch (const TRPCException& e) {
2 !
251
        ProcessException(e, onError);
2 !
252
    }
2 ✔
253
}
2 ✔
254

255
void TRPCDeviceHandler::Probe(const Json::Value& request,
2 ✔
256
                              WBMQTT::TMqttRpcServer::TResultCallback onResult,
257
                              WBMQTT::TMqttRpcServer::TErrorCallback onError)
258
{
259
    ValidateRPCRequest(request, RequestDeviceProbeSchema);
2 ✔
260
    try {
261
        SerialClientTaskRunner.RunTask(request, [&](PSerialDevice, PDeviceParametersCache) {
4 !
UNCOV
262
            return std::make_shared<TRPCDeviceProbeSerialClientTask>(request, onResult, onError);
×
263
        });
264
    } catch (const TRPCException& e) {
2 !
265
        ProcessException(e, onError);
2 !
266
    }
2 ✔
267
}
2 ✔
268

269
Json::Value TRPCDeviceHandler::SetPoll(const Json::Value& request)
6 ✔
270
{
271
    ValidateRPCRequest(request, RequestDeviceSetPollSchema);
6 ✔
272
    SerialClientTaskRunner.SetPoll(request, request["poll"].asBool());
6 ✔
273
    return Json::Value(Json::objectValue);
2 ✔
274
}
275
#endif
276

277
void PrepareSession(TPort& port, PSerialDevice device, int maxRetries)
108 ✔
278
{
279
    for (int i = 0; i <= maxRetries; i++) {
108 !
280
        try {
281
            device->Prepare(port, TDevicePrepareMode::WITHOUT_SETUP);
108 !
282
            break;
108 ✔
UNCOV
283
        } catch (const TSerialDeviceException& e) {
×
UNCOV
284
            if (i == maxRetries) {
×
UNCOV
285
                auto error = std::string("Failed to prepare session: ") + e.what();
×
UNCOV
286
                LOG(Warn) << port.GetDescription() << " " << device->ToString() << ": " << error;
×
UNCOV
287
                throw TRPCException(error, TRPCResultCode::RPC_WRONG_PARAM_VALUE);
×
UNCOV
288
            }
×
UNCOV
289
        }
×
290
    }
291
}
108 ✔
292

293
void EndSession(TPort& port, PSerialDevice device)
108 ✔
294
{
295
    try {
296
        device->EndSession(port);
108 !
UNCOV
297
    } catch (const TSerialDeviceException& e) {
×
UNCOV
298
        LOG(Warn) << port.GetDescription() << " " << device->ToString() << " unable to end session: " << e.what();
×
UNCOV
299
    }
×
300
}
108 ✔
301

302
TRPCRegisterList CreateChannelsRegisterList(const TDeviceProtocolParams& protocolParams,
18 ✔
303
                                            PSerialDevice device,
304
                                            const Json::Value& channels)
305
{
306
    // nullptr checks are needed for tests
307
    auto fwVersion = device ? device->GetWbFwVersion() : std::string();
18 !
308
    auto checkUnsupported = device && device->IsWbDevice();
18 !
309

310
    TRPCRegisterList registerList;
18 ✔
311
    for (const auto& item: channels) {
134 !
312
        if (item["address"].isNull() || !IsSupportedByFw(item, fwVersion)) {
58 !
UNCOV
313
            continue;
×
314
        }
315
        registerList.push_back(
58 !
316
            CreateRPCRegister(protocolParams, device, item["id"].asString(), item, checkUnsupported));
116 !
317
    }
318
    return registerList;
18 ✔
319
}
18 ✔
320

321
TRPCRegisterList CreateParametersRegisterList(const TDeviceProtocolParams& protocolParams,
146 ✔
322
                                              PSerialDevice device,
323
                                              const Json::Value& parameters,
324
                                              const Json::Value& knownValues,
325
                                              const std::set<std::string>& ids)
326
{
327
    // nullptr checks are needed for tests
328
    auto fwVersion = device ? device->GetWbFwVersion() : std::string();
146 !
329
    auto checkUnsupported = device && device->IsWbDevice();
146 !
330

331
    // The fw variants of a parameter (declarations with the same id and condition) define the same register,
332
    // the first variant supported by the device firmware gives the register of the group.
333
    // The enum matters only for the unsupported value marker check and no firmware adds 0xFFFE to an enum
334
    std::set<std::pair<std::string, std::string>> createdRegisters;
146 ✔
335
    TRPCRegisterList registerList;
146 ✔
336
    for (auto it = parameters.begin(); it != parameters.end(); ++it) {
1,098 !
337
        const auto& item = *it;
476 !
338
        auto id = parameters.isObject() ? it.key().asString() : item["id"].asString();
476 !
339
        if (item["address"].isNull() || !knownValues[id].isNull()) {
476 !
340
            continue;
16 ✔
341
        }
342
        if (!ids.empty() && !ids.count(id)) {
460 !
343
            continue;
120 ✔
344
        }
345
        if (!IsSupportedByFw(item, fwVersion)) {
340 !
346
            continue;
22 ✔
347
        }
348
        if (createdRegisters.emplace(id, item["condition"].asString()).second) {
318 !
349
            registerList.push_back(CreateRPCRegister(protocolParams, device, id, item, checkUnsupported));
298 !
350
        }
351
    }
476 ✔
352
    return registerList;
146 ✔
353
}
146 ✔
354

355
void ReadRegisterList(TPort& port, PSerialDevice device, TRPCRegisterList& registerList, int maxRetries)
96 ✔
356
{
357
    if (registerList.size() == 0) {
96 ✔
358
        return;
14 ✔
359
    }
360

361
    TRegisterComparePredicate compare;
362
    std::sort(registerList.begin(), registerList.end(), [compare](TRPCRegister& a, TRPCRegister& b) {
82 !
363
        return compare(b.Register, a.Register);
176 ✔
364
    });
365

366
    size_t index = 0;
82 ✔
367
    std::string error;
82 ✔
368
    while (index < registerList.size() && error.empty()) {
246 !
369
        auto first = registerList[index].Register;
164 ✔
370
        auto range = device->CreateRegisterRange();
164 !
371
        while (index < registerList.size() &&
586 ✔
372
               range->Add(port, registerList[index].Register, std::chrono::milliseconds::max()))
586 !
373
        {
374
            ++index;
170 ✔
375
        }
376
        for (int i = 0; i <= maxRetries; ++i) {
164 !
377
            try {
378
                device->ReadRegisterRange(port, range, true);
174 ✔
379
                break;
154 ✔
380
            } catch (const TSerialDevicePermanentRegisterException& e) {
10 !
381
                LOG(Warn) << port.GetDescription() << " " << device->ToString() << ": "
20 !
382
                          << "Failed to read " << std::to_string(range->RegisterList().size())
20 !
383
                          << " registers starting from <" << first->GetConfig()->ToString() + ">: " + e.what();
10 !
384
                auto modbusDevice = dynamic_cast<TModbusDevice*>(device.get());
10 !
385
                if (modbusDevice != nullptr &&
20 !
386
                    modbusDevice->GetContinuousReadStatus() == TContinuousReadStatus::DISABLED)
10 !
387
                {
388
                    for (const auto& reg: range->RegisterList()) {
16 !
389
                        reg->SetSupported(false);
8 !
390
                    }
391
                }
392
                break;
10 ✔
393
            } catch (const TSerialDeviceException& e) {
10 !
UNCOV
394
                if (i == maxRetries) {
×
UNCOV
395
                    error = "Failed to read " + std::to_string(range->RegisterList().size()) +
×
UNCOV
396
                            " registers starting from <" + first->GetConfig()->ToString() + ">: " + e.what();
×
397
                }
UNCOV
398
            }
×
399
        }
400
    }
164 ✔
401

402
    if (!error.empty()) {
82 !
UNCOV
403
        LOG(Warn) << port.GetDescription() << " " << device->ToString() << ": " << error;
×
UNCOV
404
        throw TRPCException(error, TRPCResultCode::RPC_WRONG_PARAM_VALUE);
×
405
    }
406
}
82 ✔
407

408
Json::Value RawValueToJSON(const TRegisterConfig& reg, TRegisterValue val)
166 ✔
409
{
410
    auto str = ConvertFromRawValue(reg, val);
166 !
411
    try {
412
        if (str.find('.') == std::string::npos) {
166 ✔
413
            if (str.at(0) == '-') {
162 !
414
                return static_cast<Json::Int64>(std::stoll(str.c_str(), 0));
6 !
415
            }
416
            auto value = std::stoull(str.c_str(), 0);
322 !
417
            if (value <= INT64_MAX) {
158 !
418
                // cast value to signed integer to match default Json::Value type for integers
419
                return static_cast<Json::Int64>(value);
158 !
420
            }
UNCOV
421
            return static_cast<Json::UInt64>(value);
×
422
        }
423
        return std::stod(str.c_str(), 0);
12 !
424
    } catch (const std::invalid_argument&) {
2 !
425
        return str;
2 !
426
    }
2 ✔
427
}
166 ✔
428

429
bool RegisterGotValue(const TRPCRegister& item)
26 ✔
430
{
431
    return item.Register->IsSupported() && !item.Register->GetErrorState().test(TRegister::TError::ReadError) &&
40 !
432
           item.Register->GetValue().GetType() != TRegisterValue::ValueType::Undefined;
40 !
433
}
434

435
void MergeRegisterListValues(const TRPCRegisterList& registerList, Json::Value& values)
12 ✔
436
{
437
    for (const auto& item: registerList) {
34 ✔
438
        if (RegisterGotValue(item)) {
22 !
439
            values[item.Id] = RawValueToJSON(*item.Register->GetConfig(), item.Register->GetValue());
6 !
440
        }
441
    }
442
}
12 ✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc