Skip to content

Commit 0d8a950

Browse files
Add initial project structure with main entry point, MODBUS register map, and fault record parsing logic
0 parents  commit 0d8a950

29 files changed

Lines changed: 2889 additions & 0 deletions

.idea/.gitignore

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/go.imports.xml

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/misc.xml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/modules.xml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/vcs.xml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CLAUDE.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project
6+
7+
Go application for interfacing with SRNE ASF-series hybrid inverters via MODBUS protocol over Solarman V5 wifi adaptors.
8+
9+
**Planned interfaces** (in priority order):
10+
1. CLI — dump/modify registers, verify communication
11+
2. REST API
12+
3. MQTT event publishing
13+
4. TypeScript/React frontend
14+
5. Home Assistant integration
15+
16+
**Multi-inverter:** Systems can run in parallel — multiple read endpoints, single master for writes.
17+
18+
**Test hardware:** SRNE ASP48100U200-H (product type 4, protocol V1.07, SW V8.18)
19+
20+
## Environment Setup
21+
22+
- **mise** manages the Go toolchain version
23+
- Run `mise install` to set up the environment
24+
- Prefix commands with `mise exec --` or use `mise shell` to activate the environment
25+
26+
## Commands
27+
28+
- **Build**: `mise exec -- go build ./...`
29+
- **Run tests**: `mise exec -- go test ./...`
30+
- **Run a single test**: `mise exec -- go test ./... -run TestName`
31+
32+
## Architecture
33+
34+
- `modbus/` — MODBUS RTU framing, CRC16, `Client` interface, `Session` (per-connection register cache)
35+
- `solarman/` — Solarman V5 TCP transport implementing `modbus.Client`
36+
- `register/` — Register definitions with `ScaleFunc` for context-aware value scaling
37+
- `cmd/` — Cobra CLI commands (read, write, dump, info, scan)
38+
39+
### Key Design Decisions
40+
41+
- **`modbus.Client` interface** — all CLI/API code uses this interface, not the solarman package directly. Future RS485 transport just needs to implement it.
42+
- **`modbus.Session`** — wraps a Client with per-connection register cache. Scale functions receive a `modbus.Lookup` to resolve dependent registers on demand (e.g., system voltage for 12V-base scaling). Cache is invalidated on any write.
43+
- **`register.ScaleFunc`**`func(raw float64, lookup modbus.Lookup) float64`. Reusable instances: `Mul1`, `Mul01`, `Mul001`, `Mul10`, `Voltage12V`. The `Voltage12V` scaler reads register 0xE003 via the session cache to apply systemVoltage/12 multiplier.
44+
- **Bulk reads with smart batching** — register groups are read in contiguous spans up to 32 registers, with gaps >4 registers causing a span split.
45+
- **Capability probing** — registers marked `Optional: true` are probed individually at runtime rather than gated by protocol version. This is necessary because ASP and ASF series use independent protocol version counters (ASP V1.07 supports registers from ASF V1.7+). Optional registers that fail to read are silently omitted from output. The frontend should eventually use the same probing to determine available controls.
46+
47+
## Protocol Stack
48+
49+
Communication flows: **CLI/API → Solarman V5 (TCP:8899) → MODBUS RTU → Inverter**
50+
51+
### Solarman V5 Protocol
52+
- Wraps MODBUS RTU frames in a TCP envelope. See `docs/solarman_v5_protocol.md`.
53+
- **Dongle serial number is required** — without it, the dongle ACKs but doesn't forward to the inverter. Auto-detected on first request: send with serial=0, read the ACK to learn the serial from response bytes 7-10, then resend.
54+
- Other services (ha-solarman, cloud) may share the dongle connection. Their responses interleave — filter by slave ID and CRC.
55+
56+
### SRNE MODBUS
57+
- Function codes: 0x03 (read), 0x06 (write single), 0x10 (write multiple), 0x78 (factory reset), 0x79 (clear history)
58+
- 9600 baud, 8N1, slave default 1, max 32 registers per read
59+
- CRC16 polynomial 0xA001, low byte first
60+
- Voltage settings stored in 12V-base (multiply by systemVoltage/12)
61+
- See `docs/srne_modbus_registers.md` for full register map
62+
63+
### Key Register Ranges
64+
- `0x000A-0x0049` — Product info (read-only)
65+
- `0x0100-0x0111` — Battery/PV realtime data (read-only)
66+
- `0x0200-0x0237` — Inverter/grid/load data (read-only)
67+
- `0xDF00-0xDF0D` — Device control (write-only: power, reset, sleep)
68+
- `0xE001-0xE025` — Battery settings (read/write)
69+
- `0xE026-0xE04D` — Timed charge/discharge with per-section SOC/voltage/power cutoffs
70+
- `0xE200-0xE21B` — Inverter settings (read/write)
71+
- `0xF000-0xF04B` — Statistics/historical data (read-only)
72+
- `0xF800-0xF9FF` — Fault history (read-only)
73+
74+
## Reference
75+
76+
- SRNE MODBUS protocol PDFs: https://github.com/shakthisachintha/SRNE-Hybrid-Inverter-Monitor/tree/master/Resources
77+
- Prior art (ha-solarman): https://github.com/davidrapan/ha-solarman — Solarman V5 protocol implementation, SRNE inverter profile
78+
- V1.96 protocol with changelog: https://github.com/krimsonkla/srne_ble_modbus — full protocol as markdown in `/resources/`, includes version history showing when each register was added
79+
- V2.08 protocol + ESPHome YAML: https://github.com/phinix-org/SRNE-inverters-by-modbus-rs485 — latest known protocol PDF, complete ESPHome register definitions
80+
- V1.7 CSV register list: HotNoob/PythonProtocolGateway

cmd/dump.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strings"
7+
8+
"github.com/daniel-sullivan/srne-solar-controller/modbus"
9+
"github.com/daniel-sullivan/srne-solar-controller/register"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
var dumpCmd = &cobra.Command{
14+
Use: "dump <group>",
15+
Short: "Dump a register group with human-readable output",
16+
Long: func() string {
17+
groups := register.AllGroups()
18+
names := make([]string, 0, len(groups))
19+
for name := range groups {
20+
names = append(names, name)
21+
}
22+
sort.Strings(names)
23+
return fmt.Sprintf("Dump a named register group.\n\nAvailable groups: all, faults, %s", strings.Join(names, ", "))
24+
}(),
25+
Args: cobra.ExactArgs(1),
26+
RunE: func(cmd *cobra.Command, args []string) error {
27+
client, err := newClient()
28+
if err != nil {
29+
return err
30+
}
31+
defer client.Close()
32+
33+
session := modbus.NewSession(client)
34+
groupName := args[0]
35+
36+
if groupName == "faults" {
37+
return dumpFaultHistory(session)
38+
}
39+
40+
if groupName == "all" {
41+
for _, entry := range register.OrderedGroups() {
42+
if err := dumpGroup(session, entry.Group); err != nil {
43+
fmt.Printf(" ERROR: %v\n", err)
44+
}
45+
fmt.Println()
46+
}
47+
if err := dumpFaultHistory(session); err != nil {
48+
fmt.Printf(" ERROR: %v\n", err)
49+
}
50+
return nil
51+
}
52+
53+
groups := register.AllGroups()
54+
group, ok := groups[groupName]
55+
if !ok {
56+
names := make([]string, 0, len(groups))
57+
for name := range groups {
58+
names = append(names, name)
59+
}
60+
sort.Strings(names)
61+
return fmt.Errorf("unknown group %q, available: all, faults, %s", groupName, strings.Join(names, ", "))
62+
}
63+
64+
return dumpGroup(session, group)
65+
},
66+
}
67+
68+
func init() {
69+
rootCmd.AddCommand(dumpCmd)
70+
}
71+
72+
func dumpGroup(session *modbus.Session, group register.Group) error {
73+
fmt.Printf("=== %s ===\n", group.Name)
74+
if len(group.Registers) == 0 {
75+
return nil
76+
}
77+
78+
const maxRegsPerRead = 32
79+
if err := bulkRead(session, group, maxRegsPerRead); err != nil {
80+
return err
81+
}
82+
83+
for _, reg := range group.Registers {
84+
regCount := int(reg.RegCount())
85+
regValues := make([]uint16, regCount)
86+
ok := true
87+
for i := 0; i < regCount; i++ {
88+
v, err := session.Lookup(reg.Address + uint16(i))
89+
if err != nil {
90+
ok = false
91+
break
92+
}
93+
regValues[i] = v
94+
}
95+
96+
if !ok {
97+
if reg.Optional {
98+
continue // silently skip unavailable optional registers
99+
}
100+
fmt.Printf(" 0x%04X %-35s <read error>\n", reg.Address, reg.Name)
101+
continue
102+
}
103+
104+
formatted := register.FormatValue(reg, regValues, session.Lookup)
105+
106+
extra := enumLabel(reg, regValues)
107+
if extra != "" {
108+
formatted = fmt.Sprintf("%s (%s)", formatted, extra)
109+
}
110+
111+
fmt.Printf(" 0x%04X %-35s %s\n", reg.Address, reg.Name, formatted)
112+
}
113+
return nil
114+
}
115+
116+
// bulkRead reads all registers in a group in batched reads and stores them in the session cache.
117+
// Optional registers are read in their own spans so failures don't affect required registers.
118+
func bulkRead(session *modbus.Session, group register.Group, maxPerRead int) error {
119+
type span struct {
120+
start, end uint16
121+
optional bool
122+
}
123+
var spans []span
124+
125+
for _, reg := range group.Registers {
126+
end := reg.Address + reg.RegCount()
127+
// Optional registers always get their own span to isolate failures
128+
if reg.Optional {
129+
spans = append(spans, span{reg.Address, end, true})
130+
continue
131+
}
132+
if len(spans) > 0 {
133+
last := &spans[len(spans)-1]
134+
gap := int(reg.Address) - int(last.end)
135+
if !last.optional && gap >= 0 && gap <= 4 && int(end-last.start) <= maxPerRead {
136+
last.end = end
137+
continue
138+
}
139+
}
140+
spans = append(spans, span{reg.Address, end, false})
141+
}
142+
143+
for _, s := range spans {
144+
count := s.end - s.start
145+
values, err := session.ReadRegisters(s.start, count)
146+
if err != nil {
147+
if !s.optional {
148+
return fmt.Errorf("read 0x%04X-0x%04X: %w", s.start, s.end-1, err)
149+
}
150+
// Optional register not available on this device — skip silently
151+
continue
152+
}
153+
session.Store(s.start, values)
154+
}
155+
return nil
156+
}
157+
158+
func dumpFaultHistory(session *modbus.Session) error {
159+
fmt.Println("=== Fault History ===")
160+
161+
count := 0
162+
for i := 0; i < register.FaultRecordCount; i++ {
163+
addr := uint16(register.FaultHistoryBase) + uint16(i*register.FaultRecordSize)
164+
values, err := session.ReadRegisters(addr, uint16(register.FaultRecordSize))
165+
if err != nil {
166+
return fmt.Errorf("read fault record %d at 0x%04X: %w", i, addr, err)
167+
}
168+
169+
record := register.ParseFaultRecord(i, values)
170+
if record.IsEmpty() {
171+
continue
172+
}
173+
174+
fmt.Println(register.FormatFaultRecord(record))
175+
count++
176+
}
177+
178+
if count == 0 {
179+
fmt.Println(" (no faults recorded)")
180+
}
181+
return nil
182+
}
183+
184+
func enumLabel(reg register.Register, values []uint16) string {
185+
if len(values) == 0 {
186+
return ""
187+
}
188+
switch reg.Address {
189+
case 0x0210:
190+
return register.MachineState(values[0])
191+
case 0x010B:
192+
return register.ChargeStatus(values[0])
193+
case 0xE004:
194+
return register.BatteryType(values[0])
195+
case 0xE204:
196+
return register.OutputPriority(values[0])
197+
case 0xE20F:
198+
return register.ChargerPriority(values[0])
199+
}
200+
return ""
201+
}

0 commit comments

Comments
 (0)