INDI Driver Development Best Practices
This guide provides a comprehensive set of best practices for developing INDI drivers. Following these guidelines will help you create robust, maintainable, and user-friendly drivers that work well with the INDI ecosystem.
Code Organization
Project Structure
Organize your driver project with a clear and consistent structure:
- Header Files: Place all header files in the root directory or a dedicated
include
directory. - Source Files: Place all source files in the root directory or a dedicated
src
directory. - Build Files: Place CMake files in the root directory.
- Documentation: Place documentation files in a dedicated
doc
directory. - Examples: Place example code and configuration files in a dedicated
examples
directory. - Tests: Place test code in a dedicated
tests
directory.
Example project structure:
indi-mydriver/
├── CMakeLists.txt
├── config.h.cmake
├── indi_mydriver.xml.cmake
├── README.md
├── include/
│ ├── mydriver.h
│ └── mydriver_utils.h
├── src/
│ ├── mydriver.cpp
│ ├── mydriver_utils.cpp
│ └── main.cpp
├── doc/
│ ├── manual.md
│ └── api.md
├── examples/
│ ├── config.xml
│ └── script.sh
└── tests/
├── test_mydriver.cpp
└── CMakeLists.txt
Class Design
Design your driver classes with clear responsibilities and interfaces:
- Single Responsibility Principle: Each class should have a single responsibility.
- Interface Segregation: Define clear interfaces for different aspects of your driver.
- Dependency Injection: Use dependency injection to make your code more testable.
- Encapsulation: Hide implementation details behind well-defined interfaces.
Example class design:
// MyDriver.h
class MyDriver : public INDI::DefaultDevice
{
public:
MyDriver();
virtual ~MyDriver() = default;
// DefaultDevice overrides
virtual const char *getDefaultName() override;
virtual bool initProperties() override;
virtual bool updateProperties() override;
virtual bool ISNewNumber(const char *dev, const char *name, double values[], char *names[], int n) override;
virtual bool ISNewSwitch(const char *dev, const char *name, ISState *states, char *names[], int n) override;
virtual bool ISNewText(const char *dev, const char *name, char *texts[], char *names[], int n) override;
virtual bool ISNewBLOB(const char *dev, const char *name, int sizes[], int blobsizes[], char *blobs[], char *formats[], char *names[], int n) override;
virtual bool ISSnoopDevice(XMLEle *root) override;
// Connection overrides
virtual bool Connect() override;
virtual bool Disconnect() override;
// Timer callback
virtual void TimerHit() override;
private:
// Helper methods
bool sendCommand(const char *cmd, char *res = nullptr, int reslen = 0);
bool readResponse(char *res, int reslen);
bool getStatus();
bool setConfig(const char *config);
// Properties
INDI::PropertyText ConfigTP {1};
INDI::PropertySwitch ModeSP {3};
INDI::PropertyNumber SettingsNP {4};
INDI::PropertyLight StatusLP {2};
// State variables
bool Connected = false;
bool Configured = false;
int Mode = 0;
double Settings[4] = {0, 0, 0, 0};
int Status = 0;
// Connection
Connection::Serial *serialConnection = nullptr;
int PortFD = -1;
};
Error Handling
Robust Error Checking
Always check for errors and handle them appropriately:
- Return Values: Check return values from all function calls.
- Error Codes: Use error codes to indicate different types of errors.
- Exceptions: Use exceptions for exceptional conditions, but handle them properly.
- Logging: Log errors with appropriate severity levels.
Example error handling:
bool MyDriver::sendCommand(const char *cmd, char *res, int reslen)
{
// Check if the port is open
if (PortFD < 0)
{
LOG_ERROR("Serial port not open");
return false;
}
// Write the command
int nbytes_written = write(PortFD, cmd, strlen(cmd));
if (nbytes_written < 0)
{
LOGF_ERROR("Error writing to device: %s", strerror(errno));
return false;
}
// If no response is expected, return success
if (res == nullptr || reslen <= 0)
return true;
// Read the response
if (!readResponse(res, reslen))
{
LOG_ERROR("Error reading response from device");
return false;
}
return true;
}
Graceful Degradation
Design your driver to degrade gracefully when errors occur:
- Partial Functionality: If some features are unavailable, continue with the available ones.
- Retry Mechanisms: Implement retry mechanisms for transient errors.
- Fallback Options: Provide fallback options when the preferred approach fails.
- User Feedback: Inform the user about errors and their implications.
Example graceful degradation:
bool MyDriver::Connect()
{
// Call the parent's Connect method
bool result = INDI::DefaultDevice::Connect();
if (result)
{
// Get the file descriptor for the serial port
PortFD = serialConnection->getPortFD();
// Try to get the device status
if (!getStatus())
{
// If we can't get the status, we can still continue with limited functionality
LOG_WARN("Could not get device status, some features may be unavailable");
Status = 0; // Assume a default status
}
// Try to configure the device
if (!setConfig(ConfigTP[0].getText()))
{
// If we can't configure the device, we can still continue with default settings
LOG_WARN("Could not configure device, using default settings");
Configured = false;
}
else
{
Configured = true;
}
// Set the connection status
Connected = true;
// Start the timer
SetTimer(POLLMS);
LOG_INFO("Device connected successfully");
}
return result;
}
Resource Management
Memory Management
Manage memory carefully to avoid leaks and corruption:
- RAII: Use Resource Acquisition Is Initialization (RAII) to manage resources.
- Smart Pointers: Use smart pointers instead of raw pointers.
- Memory Allocation: Be careful with memory allocation and deallocation.
- Buffer Overflows: Prevent buffer overflows by checking bounds.
Example memory management:
// Use smart pointers for dynamic memory
std::unique_ptr<uint8_t[]> buffer(new uint8_t[size]);
// Use RAII for file handling
{
std::ifstream file(filename, std::ios::binary);
if (file)
{
// File will be automatically closed when the scope ends
file.read(reinterpret_cast<char *>(buffer.get()), size);
}
}
// Prevent buffer overflows
void MyDriver::readResponse(char *res, int reslen)
{
// Ensure we don't overflow the buffer
int nbytes_read = read(PortFD, res, reslen - 1);
if (nbytes_read >= 0)
{
// Null-terminate the string
res[nbytes_read] = '\0';
}
}
File Descriptors
Manage file descriptors carefully to avoid leaks:
- Open/Close: Always close file descriptors that you open.
- Error Checking: Check for errors when opening and closing file descriptors.
- Resource Limits: Be aware of resource limits on the number of open file descriptors.
Example file descriptor management:
bool MyDriver::Connect()
{
// Open the serial port
PortFD = open(serialConnection->port(), O_RDWR | O_NOCTTY);
if (PortFD < 0)
{
LOGF_ERROR("Error opening serial port %s: %s", serialConnection->port(), strerror(errno));
return false;
}
// Configure the serial port
// ...
return true;
}
bool MyDriver::Disconnect()
{
// Close the serial port
if (PortFD >= 0)
{
close(PortFD);
PortFD = -1;
}
return true;
}
Threads
Use threads carefully to avoid race conditions and deadlocks:
- Thread Safety: Ensure that shared resources are accessed in a thread-safe manner.
- Synchronization: Use appropriate synchronization mechanisms (mutexes, condition variables, etc.).
- Thread Pools: Consider using thread pools for better resource management.
- Thread Lifecycle: Manage thread lifecycle carefully (creation, execution, termination).
Example thread management:
class MyDriver : public INDI::DefaultDevice
{
public:
// ...
private:
// Thread-related members
std::thread workerThread;
std::mutex mutex;
std::condition_variable cv;
bool terminateThread = false;
bool dataReady = false;
std::vector<uint8_t> sharedData;
// Thread function
void workerFunction();
};
void MyDriver::workerFunction()
{
while (true)
{
std::unique_lock<std::mutex> lock(mutex);
cv.wait(lock, [this] { return dataReady || terminateThread; });
if (terminateThread)
break;
// Process the shared data
processData(sharedData);
// Reset the flag
dataReady = false;
}
}
bool MyDriver::Connect()
{
// ...
// Start the worker thread
terminateThread = false;
workerThread = std::thread(&MyDriver::workerFunction, this);
// ...
}
bool MyDriver::Disconnect()
{
// ...
// Terminate the worker thread
{
std::lock_guard<std::mutex> lock(mutex);
terminateThread = true;
}
cv.notify_one();
if (workerThread.joinable())
workerThread.join();
// ...
}
void MyDriver::newData(const std::vector<uint8_t> &data)
{
{
std::lock_guard<std::mutex> lock(mutex);
sharedData = data;
dataReady = true;
}
cv.notify_one();
}
Performance Optimization
Efficient Algorithms
Use efficient algorithms and data structures:
- Time Complexity: Choose algorithms with appropriate time complexity.
- Space Complexity: Be mindful of memory usage.
- Data Structures: Choose appropriate data structures for your use case.
- Algorithmic Optimizations: Apply algorithmic optimizations where appropriate.
Example efficient algorithm:
// Efficient string parsing using string_view (C++17)
std::vector<std::string_view> split(std::string_view str, char delimiter)
{
std::vector<std::string_view> result;
size_t start = 0;
size_t end = str.find(delimiter);
while (end != std::string_view::npos)
{
result.push_back(str.substr(start, end - start));
start = end + 1;
end = str.find(delimiter, start);
}
result.push_back(str.substr(start));
return result;
}
Minimizing I/O
Minimize I/O operations to improve performance:
- Buffering: Use buffering to reduce the number of I/O operations.
- Batching: Batch I/O operations where possible.
- Asynchronous I/O: Consider using asynchronous I/O for better performance.
- Memory-Mapped I/O: Consider using memory-mapped I/O for large files.
Example I/O optimization:
// Use a buffer to reduce the number of I/O operations
class BufferedReader
{
public:
BufferedReader(int fd, size_t bufferSize = 4096)
: fd(fd), bufferSize(bufferSize), buffer(new char[bufferSize]), bufferPos(0), bufferEnd(0)
{
}
~BufferedReader()
{
delete[] buffer;
}
ssize_t read(void *data, size_t size)
{
char *dest = static_cast<char *>(data);
size_t remaining = size;
size_t totalRead = 0;
while (remaining > 0)
{
// If the buffer is empty, refill it
if (bufferPos >= bufferEnd)
{
bufferPos = 0;
bufferEnd = ::read(fd, buffer, bufferSize);
if (bufferEnd <= 0)
break; // EOF or error
}
// Copy data from the buffer
size_t toCopy = std::min(remaining, static_cast<size_t>(bufferEnd - bufferPos));
memcpy(dest + totalRead, buffer + bufferPos, toCopy);
bufferPos += toCopy;
totalRead += toCopy;
remaining -= toCopy;
}
return totalRead;
}
private:
int fd;
size_t bufferSize;
char *buffer;
ssize_t bufferPos;
ssize_t bufferEnd;
};
Profiling and Optimization
Profile your code to identify and optimize bottlenecks:
- Profiling Tools: Use profiling tools to identify bottlenecks.
- Benchmarking: Benchmark critical sections of your code.
- Optimization Techniques: Apply appropriate optimization techniques.
- Premature Optimization: Avoid premature optimization.
Example profiling and optimization:
// Simple benchmarking function
template <typename Func>
double benchmark(Func func, int iterations = 1000)
{
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < iterations; ++i)
func();
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> elapsed = end - start;
return elapsed.count() / iterations;
}
// Usage
double time = benchmark([&]() {
// Code to benchmark
processImage(image);
});
LOGF_DEBUG("Image processing took %.6f seconds", time);
Testing and Debugging
Unit Testing
Write unit tests to verify the correctness of your code:
- Test Framework: Use a test framework like Google Test or Catch2.
- Test Coverage: Aim for high test coverage.
- Test Isolation: Isolate tests from each other.
- Test Fixtures: Use test fixtures for common setup and teardown.
Example unit test:
// Using Google Test
#include <gtest/gtest.h>
#include "mydriver.h"
class MyDriverTest : public ::testing::Test
{
protected:
void SetUp() override
{
// Set up test environment
driver = new MyDriver();
driver->initProperties();
}
void TearDown() override
{
// Clean up test environment
delete driver;
}
MyDriver *driver;
};
TEST_F(MyDriverTest, InitProperties)
{
// Test that properties are initialized correctly
ASSERT_TRUE(driver->getProperty("CONFIG"));
ASSERT_TRUE(driver->getProperty("MODE"));
ASSERT_TRUE(driver->getProperty("SETTINGS"));
ASSERT_TRUE(driver->getProperty("STATUS"));
}
TEST_F(MyDriverTest, Connect)
{
// Test connection (with mocked serial port)
ASSERT_TRUE(driver->Connect());
ASSERT_TRUE(driver->isConnected());
}
TEST_F(MyDriverTest, SendCommand)
{
// Test sending a command (with mocked serial port)
driver->Connect();
char response[32];
ASSERT_TRUE(driver->sendCommand("TEST", response, sizeof(response)));
ASSERT_STREQ(response, "OK");
}
Integration Testing
Write integration tests to verify that your driver works correctly with the INDI system:
- Test Environment: Set up a test environment that mimics the real environment.
- Test Scenarios: Test common usage scenarios.
- Error Conditions: Test error conditions and edge cases.
- Performance Testing: Test performance under various conditions.
Example integration test:
// Using a custom test framework
#include "test_framework.h"
#include <indiapi.h>
#include <defaultdevice.h>
#include <indidriverinterface.h>
class INDIDriverTest : public TestCase
{
public:
void setUp() override
{
// Set up the INDI driver interface
driverInterface = new INDI::DriverInterface();
driverInterface->addDriver("indi_mydriver");
}
void tearDown() override
{
// Clean up
delete driverInterface;
}
void testDriverInitialization()
{
// Test that the driver initializes correctly
ASSERT_TRUE(driverInterface->isDriverLoaded("My Driver"));
}
void testDriverConnection()
{
// Test that the driver can connect to a device
INDI::BaseDevice *device = driverInterface->getDevice("My Driver");
ASSERT_NOT_NULL(device);
// Connect to the device
ISwitchVectorProperty *connectionSP = device->getSwitch("CONNECTION");
ASSERT_NOT_NULL(connectionSP);
ISwitch *connectSwitch = IUFindSwitch(connectionSP, "CONNECT");
ASSERT_NOT_NULL(connectSwitch);
connectSwitch->s = ISS_ON;
driverInterface->sendNewSwitch(connectionSP);
// Wait for the connection to complete
ASSERT_TRUE(waitForPropertyState(device, "CONNECTION", IPS_OK, 5000));
}
private:
INDI::DriverInterface *driverInterface;
};
// Register the tests
REGISTER_TEST(INDIDriverTest, testDriverInitialization);
REGISTER_TEST(INDIDriverTest, testDriverConnection);
Debugging Techniques
Use effective debugging techniques to identify and fix issues:
- Logging: Use logging to track the execution flow and state.
- Debuggers: Use debuggers to step through code and inspect state.
- Assertions: Use assertions to catch programming errors.
- Tracing: Use tracing to follow the execution path.
Example debugging techniques:
// Logging
LOGF_DEBUG("Processing image: %s", filename);
LOGF_DEBUG("Image dimensions: %dx%d", width, height);
LOGF_DEBUG("Processing took %.3f seconds", elapsed);
// Assertions
assert(buffer != nullptr && "Buffer should not be null");
assert(size > 0 && "Size should be positive");
assert(index < count && "Index out of bounds");
// Tracing
void MyDriver::processCommand(const char *cmd)
{
LOGF_DEBUG("Entering processCommand: %s", cmd);
// Process the command
// ...
LOG_DEBUG("Exiting processCommand");
}
Documentation
Code Documentation
Document your code thoroughly:
- Function Documentation: Document the purpose, parameters, return values, and exceptions of functions.
- Class Documentation: Document the purpose, responsibilities, and usage of classes.
- File Documentation: Document the purpose and contents of files.
- Implementation Notes: Document non-obvious implementation details.
Example code documentation:
/**
* @brief Send a command to the device and optionally read a response.
*
* This function sends a command to the device over the serial port and
* optionally reads a response. It handles error checking and logging.
*
* @param cmd The command to send.
* @param res Buffer to store the response (can be nullptr if no response is expected).
* @param reslen Size of the response buffer.
* @return true if the command was sent successfully and a valid response was received (if expected).
* @return false if an error occurred.
*/
bool MyDriver::sendCommand(const char *cmd, char *res, int reslen)
{
// Implementation
// ...
}
User Documentation
Provide comprehensive user documentation:
- Installation Guide: Document how to install and configure the driver.
- User Manual: Document how to use the driver.
- API Reference: Document the driver’s API for developers.
- Examples: Provide examples of common usage scenarios.
Example user documentation:
# My Driver User Manual
## Installation
### Prerequisites
- INDI Library (version 1.8.0 or later)
- CMake (version 3.10 or later)
- GCC (version 7.0 or later)
### Building from Source
```bash
mkdir build
cd build
cmake ..
make
sudo make install
```
## Configuration
The driver supports the following configuration options:
- **Port**: Serial port to use (e.g., `/dev/ttyUSB0`).
- **Baud Rate**: Serial port baud rate (default: 9600).
- **Config File**: Path to the device configuration file.
## Usage
### Basic Usage
1. Start the INDI server with the driver:
```bash
indiserver indi_mydriver
```
2. Connect to the INDI server using your favorite INDI client.
3. Connect to the device by setting the port and clicking the "Connect" button.
4. Configure the device by setting the configuration options.
5. Use the device features through the INDI client interface.
### Advanced Usage
...
Compatibility and Portability
Cross-Platform Compatibility
Make your driver compatible with different platforms:
- Platform-Specific Code: Isolate platform-specific code.
- Conditional Compilation: Use conditional compilation for platform-specific features.
- Portable Libraries: Use portable libraries for platform-independent functionality.
- Feature Detection: Use feature detection instead of platform detection.
Example cross-platform compatibility:
// Platform-specific includes
#ifdef _WIN32
#include <windows.h>
#else
#include <unistd.h>
#include <termios.h>
#include <fcntl.h>
#endif
// Platform-specific code
bool MyDriver::sleep(int milliseconds)
{
#ifdef _WIN32
Sleep(milliseconds);
return true;
#else
usleep(milliseconds * 1000);
return true;
#endif
}
// Feature detection
#if defined(__cpp_lib_filesystem)
#include <filesystem>
namespace fs = std::filesystem;
#elif defined(__cpp_lib_experimental_filesystem)
#include <experimental/filesystem>
namespace fs = std::experimental::filesystem;
#else
#error "Filesystem library not available"
#endif
Backward Compatibility
Maintain backward compatibility with older versions:
- API Stability: Avoid breaking changes to the API.
- Deprecation Process: Use a deprecation process for obsolete features.
- Version Checking: Check version compatibility at runtime.
- Compatibility Layers: Provide compatibility layers for older versions.
Example backward compatibility:
// Deprecation process
[[deprecated("Use newFunction() instead")]]
void oldFunction()
{
// Forward to the new function
newFunction();
}
// Version checking
bool MyDriver::initProperties()
{
// Check INDI library version
if (INDI::getVersionMajor() < 1 || (INDI::getVersionMajor() == 1 && INDI::getVersionMinor() < 8))
{
LOG_ERROR("This driver requires INDI library version 1.8.0 or later");
return false;
}
// Initialize properties
// ...
return true;
}
Security
Input Validation
Validate all input to prevent security vulnerabilities:
- Boundary Checking: Check array bounds to prevent buffer overflows.
- Type Checking: Validate input types to prevent type confusion.
- Format String Validation: Validate format strings to prevent format string vulnerabilities.
- Command Injection: Prevent command injection by validating and sanitizing input.
Example input validation:
bool MyDriver::setConfig(const char *config)
{
// Check for null pointer
if (config == nullptr)
{
LOG_ERROR("Config is null");
return false;
}
// Check for empty string
if (*config == '\0')
{
LOG_ERROR("Config is empty");
return false;
}
// Check for maximum length
if (strlen(config) > MAX_CONFIG_LENGTH)
{
LOGF_ERROR("Config is too long (maximum length is %d)", MAX_CONFIG_LENGTH);
return false;
}
// Check for valid characters
for (const char *p = config; *p != '\0'; ++p)
{
if (!isalnum(*p) && *p != '_' && *p != '-' && *p != '.')
{
LOGF_ERROR("Config contains invalid character: %c", *p);
return false;
}
}
// Config is valid, use it
// ...
return true;
}
Secure Communication
Implement secure communication with devices:
- Encryption: Use encryption for sensitive data.
- Authentication: Implement authentication mechanisms.
- Secure Protocols: Use secure protocols for communication.
- Certificate Validation: Validate certificates for secure connections.
Example secure communication:
bool MyDriver::connectSecure()
{
// Initialize OpenSSL
SSL_library_init();
SSL_load_error_strings();
OpenSSL_add_all_algorithms();
// Create SSL context
SSL_CTX *ctx = SSL_CTX_new(TLS_client_method());
if (ctx == nullptr)
{
LOG_ERROR("Failed to create SSL context");
return false;
}
// Load certificates
if (SSL_CTX_load_verify_locations(ctx, "ca.pem", nullptr) != 1)
{
LOG_ERROR("Failed to load CA certificate");
SSL_CTX_free(ctx);
return false;
}
// Create SSL connection
SSL *ssl = SSL_new(ctx);
if (ssl == nullptr)
{
LOG_ERROR("Failed to create SSL connection");
SSL_CTX_free(ctx);
return false;
}
// Connect to the device
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0)
{
LOG_ERROR("Failed to create socket");
SSL_free(ssl);
SSL_CTX_free(ctx);
return false;
}
// Set up the address
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(host);
// Connect to the server
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0)
{
LOG_ERROR("Failed to connect to server");
close(fd);
SSL_free(ssl);
SSL_CTX_free(ctx);
return false;
}
// Associate the SSL connection with the socket
SSL_set_fd(ssl, fd);
// Perform the SSL handshake
if (SSL_connect(ssl) != 1)
{
LOG_ERROR("SSL handshake failed");
close(fd);
SSL_free(ssl);
SSL_CTX_free(ctx);
return false;
}
// Verify the certificate
X509 *cert = SSL_get_peer_certificate(ssl);
if (cert == nullptr)
{
LOG_ERROR("No certificate presented by the server");
SSL_shutdown(ssl);
close(fd);
SSL_free(ssl);
SSL_CTX_free(ctx);
return false;
}
// Certificate verification
long verifyResult = SSL_get_verify_result(ssl);
if (verifyResult != X509_V_OK)
{
LOGF_ERROR("Certificate verification failed: %s", X509_verify_cert_error_string(verifyResult));
X509_free(cert);
SSL_shutdown(ssl);
close(fd);
SSL_free(ssl);
SSL_CTX_free(ctx);
return false;
}
// Certificate is valid
X509_free(cert);
// Store the SSL connection and context for later use
this->ssl = ssl;
this->ctx = ctx;
this->fd = fd;
return true;
}
User Experience
User Interface
Design a user-friendly interface:
- Intuitive Controls: Make controls intuitive and easy to use.
- Consistent Layout: Use a consistent layout for properties.
- Meaningful Labels: Use meaningful labels for properties and controls.
- Grouping: Group related properties together.
Example user interface:
// Define enums for property indices to avoid magic numbers
// This is a good practice to make the code more readable and maintainable
enum
{
CONFIG_FILE,
CONFIG_COUNT
};
enum
{
MODE_NORMAL,
MODE_FAST,
MODE_PRECISE,
MODE_COUNT
};
enum
{
SETTING_1,
SETTING_2,
SETTING_3,
SETTING_4,
SETTING_COUNT
};
enum
{
STATUS_READY,
STATUS_BUSY,
STATUS_COUNT
};
bool MyDriver::initProperties()
{
// Call the parent's initProperties
INDI::DefaultDevice::initProperties();
// Add device-specific properties
// Connection group (already defined by DefaultDevice)
// Configuration group
ConfigTP[CONFIG_FILE].fill("CONFIG_FILE", "Config File", "");
ConfigTP.fill(getDeviceName(), "CONFIG", "Configuration", MAIN_CONTROL_TAB, IP_RW, 60, IPS_IDLE);
// Mode group
ModeSP[MODE_NORMAL].fill("MODE_NORMAL", "Normal", ISS_ON);
ModeSP[MODE_FAST].fill("MODE_FAST", "Fast", ISS_OFF);
ModeSP[MODE_PRECISE].fill("MODE_PRECISE", "Precise", ISS_OFF);
ModeSP.fill(getDeviceName(), "MODE", "Mode", MAIN_CONTROL_TAB, IP_RW, ISR_1OFMANY, 60, IPS_IDLE);
// Settings group
SettingsNP[SETTING_1].fill("SETTING_1", "Setting 1", "%.2f", 0, 100, 1, 50);
SettingsNP[SETTING_2].fill("SETTING_2", "Setting 2", "%.2f", 0, 100, 1, 50);
SettingsNP[SETTING_3].fill("SETTING_3", "Setting 3", "%.2f", 0, 100, 1, 50);
SettingsNP[SETTING_4].fill("SETTING_4", "Setting 4", "%.2f", 0, 100, 1, 50);
SettingsNP.fill(getDeviceName(), "SETTINGS", "Settings", MAIN_CONTROL_TAB, IP_RW, 60, IPS_IDLE);
// Status group
StatusLP[STATUS_READY].fill("STATUS_READY", "Ready", IPS_IDLE);
StatusLP[STATUS_BUSY].fill("STATUS_BUSY", "Busy", IPS_IDLE);
StatusLP.fill(getDeviceName(), "STATUS", "Status", MAIN_