Production-ready Solarman V5 protocol client in pure Swift, built on SwiftNIO.
SolarmanV5 enables communication with Solarman (IGEN-Tech) WiFi data logging sticks that use the proprietary V5 protocol. These loggers connect solar inverters to the Solarman Cloud and expose a local TCP interface on port 8899.
Key Insight: The V5 protocol wraps standard Modbus RTU frames, allowing direct communication with inverters without disrupting cloud operations.
- Pure Swift — No C dependencies
- SwiftNIO — High-performance async TCP networking
- Swift 6.2 — Typed throws,
Span<UInt8>parsing,Mutexrequest serialization - Full Modbus Support — All 9 function codes supported by pysolarmanv5
- Observability — swift-log, swift-metrics, ServiceLifecycle integration
This library implements the Solarman V5 protocol used by IGEN-Tech WiFi data logging sticks. Compatibility depends on your logger using the V5 protocol on TCP port 8899.
Note: Solis S3-WIFI-ST uses a different protocol and is not supported.
dependencies: [
.package(url: "https://github.com/3a4oT/solarman-swift.git", from: "1.0.0")
]Then add to your target:
.target(
name: "YourApp",
dependencies: [
.product(name: "SolarmanV5", package: "solarman-swift"),
]
)Auto-closes connection when scope exits. Best for one-off operations:
import SolarmanV5
let registers = try await withSolarmanV5Client(
host: "192.168.1.100",
serial: 1712345678
) { client in
try await client.readHoldingRegisters(address: 0, count: 10).registers
}For persistent connections with logging, metrics, and graceful shutdown:
import Logging
import SolarmanV5
import ServiceLifecycle
let logger = Logger(label: "solar")
let metrics = SolarmanMetrics()
let client = SolarmanV5Client(
host: "192.168.1.100",
serial: 1712345678,
logger: logger,
metrics: metrics
)
try await client.connect()
let response = try await client.readHoldingRegisters(address: 0, count: 10)
print(response.registers)
// Graceful shutdown with ServiceLifecycle
let group = ServiceGroup(
services: [client],
gracefulShutdownSignals: [.sigterm, .sigint],
logger: logger
)
try await group.run()let config = SolarmanClientConfiguration(
host: "192.168.1.100",
serial: 1712345678,
port: 8899, // Default V5 port
unitId: 1, // Modbus slave ID
timeout: .seconds(60), // Per pysolarmanv5 default
retries: 3, // Retry attempts
idleTimeout: .seconds(60), // Auto-disconnect on inactivity
reconnectionStrategy: .exponentialBackoff(
initialDelay: .milliseconds(100),
maxDelay: .seconds(30)
),
v5ErrorCorrection: false // Naive frame recovery (rare)
)
let client = SolarmanV5Client(
configuration: config,
logger: logger,
metrics: metrics
)| Code | Function | Method |
|---|---|---|
| 0x01 | Read Coils | readCoils(address:count:) |
| 0x02 | Read Discrete Inputs | readDiscreteInputs(address:count:) |
| 0x03 | Read Holding Registers | readHoldingRegisters(address:count:) |
| 0x04 | Read Input Registers | readInputRegisters(address:count:) |
| 0x05 | Write Single Coil | writeSingleCoil(address:value:) |
| 0x06 | Write Single Register | writeSingleRegister(address:value:) |
| 0x0F | Write Multiple Coils | writeMultipleCoils(address:values:) |
| 0x10 | Write Multiple Registers | writeMultipleRegisters(address:values:) |
| 0x16 | Mask Write Register | maskWriteRegister(address:andMask:orMask:) |
For custom function codes or debugging:
// Without CRC (auto-appended)
let response = try await client.sendRawModbusFrame([0x01, 0x03, 0x00, 0x00, 0x00, 0x0A])
// With CRC (sent as-is)
let response = try await client.sendRawModbusFrameWithCRC(frameWithCRC)| Strategy | Description |
|---|---|
.disabled |
No auto-reconnect; call connect() manually |
.immediate |
Reconnect immediately on disconnect (goburrow/modbus style) |
.exponentialBackoff(initialDelay:maxDelay:) |
Reconnect with increasing delays (pymodbus style) |
All client methods throw SolarmanClientError with typed throws:
do {
let response = try await client.readHoldingRegisters(address: 0, count: 10)
} catch .timeout {
// Connection or read timed out
} catch .modbusException(let exception) {
// Device returned Modbus exception (e.g., illegal address)
} catch .v5FrameError(let message) {
// V5 protocol error (checksum, markers, etc.)
} catch .notConnected {
// Client not connected
}| Error | Retryable | Notes |
|---|---|---|
timeout |
Yes | Network delay |
ioError |
Yes | Connection reset |
channelClosed |
Yes | Unexpected disconnect |
connectionFailed |
Yes | Initial connect failed |
modbusException |
No | Device rejected request |
v5FrameError |
No | Protocol violation |
invalidParameter |
No | Invalid input |
When SolarmanMetrics is provided, the following Prometheus-compatible metrics are recorded:
| Metric | Type | Labels |
|---|---|---|
solarman_connection_active |
Gauge | serial |
solarman_requests_total |
Counter | serial, function_code, status |
solarman_request_duration_seconds |
Timer | serial, function_code |
solarman_retries_total |
Counter | serial, function_code |
solarman_reconnections_total |
Counter | serial |
┌─────────────────────────────────────────────────────────────────┐
│ V5 Frame │
├────────┬────────┬──────────────────────────────────┬────────────┤
│ Header │ Payload │ Modbus RTU Frame │ Trailer │
│ 11 B │ 14-15 B │ (Big Endian, with CRC) │ 2 B │
└────────┴────────┴──────────────────────────────────┴────────────┘
| Field | Size | Encoding | Notes |
|---|---|---|---|
| Start | 1 | — | 0xA5 |
| Length | 2 | LE | Payload size |
| Control Code | 2 | LE | 0x4510 request, 0x1510 response |
| Sequence | 2 | LE | Request ID (echoed in response) |
| Logger Serial | 4 | LE | Data logger serial number |
| Frame Type | 1 | — | 0x02 for inverter |
| Status/Sensor | 1-2 | — | Request vs response differs |
| Timestamps | 12 | LE | Working time, power on, offset |
| Modbus RTU | var | BE | Standard Modbus frame |
| Checksum | 1 | — | sum(bytes[1..<end-1]) & 0xFF |
| End | 1 | — | 0x15 |
Requests are serialized using Synchronization.Mutex. This matches:
- pysolarmanv5: socket-based, effectively single request at a time
- Most WiFi loggers: don't support concurrent requests
Note: Transaction ID pipelining is NOT supported (V5 protocol limitation).
- Swift 6.2+
- macOS 26+, iOS 26+, or Linux (Ubuntu 24.04+)
| Package | Version | Purpose |
|---|---|---|
| modbus-swift | 1.0.0+ | ModbusCore for PDU/CRC |
| swift-nio | 2.91.0+ | TCP networking |
| swift-log | 1.7.1+ | Structured logging |
| swift-metrics | 2.7.1+ | Metrics collection |
| swift-service-lifecycle | 2.9.1+ | Graceful shutdown |
# Install SwiftFormat
brew install swiftformat
# Install pre-commit hook (runs SwiftFormat on staged files)
./Scripts/install-hooks.shThis project uses SwiftFormat with configuration in .swiftformat.
# Format all files
swiftformat .
# Check without modifying
swiftformat . --lintswift test --filter SolarmanV5| Issue | Affected Devices | Solution |
|---|---|---|
| Double CRC | DEYE, others | v5ErrorCorrection: true |
| Response delays | Various | Increase timeout |
| Connection limits | Most loggers | Use single client instance |
- pysolarmanv5 — Reference Python implementation
- pysolarmanv5 Protocol Docs — Community protocol documentation
- modbus-swift — Swift Modbus implementation
Apache License 2.0. See LICENSE for details.