From ce357af94534131a439fe43891907ccc89018237 Mon Sep 17 00:00:00 2001 From: Davi Souza <84209085+davisouzatb@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:42:53 -0300 Subject: [PATCH] Add SOFAR 4KTLM-G3 single-phase inverter profile --- README.md | 1 + .../inverter_definitions/sofar_4ktlm-g3.yaml | 312 ++++++++++++++++++ tests/test_sofar_4ktlm_g3.py | 94 ++++++ 3 files changed, 407 insertions(+) create mode 100644 custom_components/solarman/inverter_definitions/sofar_4ktlm-g3.yaml create mode 100644 tests/test_sofar_4ktlm_g3.py diff --git a/README.md b/README.md index bcdcf72..1d8c46a 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ sensor: | deye_2mppt.yaml | DEYE Microinverter with 2 MPPT Trackers | e.g. SUN600G3-EU-230 / SUN800G3-EU-230 / SUN1000G3-EU-230 | | deye_4mppt.yaml | DEYE Microinverter with 4 MPPT Trackers | e.g. SUN1300G3-EU-230 / SUN1600G3-EU-230 / SUN2000G3-EU-230 | | sofar_lsw3.yaml | SOFAR Inverters | | +| sofar_4ktlm-g3.yaml | SOFAR 4KTLM-G3 (single-phase, 2 MPPT) | tested with LSW3_15_270A_1.68; block-aligned G3 reads | | sofar_g3hyd.yaml | SOFAR Hybrid Three-Phase inverter | HYD 6000 or rebranded (three-phase), ex. ZCS Azzurro 3PH HYD-ZSS | | sofar_hyd3k-6k.yaml | SOFAR Hybrid Single-Phase inverter | HYD 6000 or rebranded (single-phase), ex. ZCS Azzurro HYD-ZSS | | solis_hybrid.yaml | SOLIS Hybrid inverter | | diff --git a/custom_components/solarman/inverter_definitions/sofar_4ktlm-g3.yaml b/custom_components/solarman/inverter_definitions/sofar_4ktlm-g3.yaml new file mode 100644 index 0000000..87a757a --- /dev/null +++ b/custom_components/solarman/inverter_definitions/sofar_4ktlm-g3.yaml @@ -0,0 +1,312 @@ +# SOFAR 4KTLM-G3: single-phase, non-hybrid, two MPPT inputs. +# Hardware-tested with LSW3_15_270A_1.68 over Solarman V5, Modbus slave 1. +# Read complete, block-aligned ranges: interior-start requests can return +# non-standard error frames which fail Modbus CRC validation on this logger. +# Other KTLM-G3 models have not been hardware-tested with this profile. +# Fault 1-12 expose raw G3 bitfields; zero means no error. The legacy Sofar +# fault descriptions must not be reused for these registers. +# No batteries, off-grid output, or L2/L3 sensors are requested. + +requests: + - start: 0x0400 + end: 0x042E + mb_functioncode: 0x03 + - start: 0x0480 + end: 0x04AF + mb_functioncode: 0x03 + - start: 0x0580 + end: 0x0589 + mb_functioncode: 0x03 + - start: 0x0680 + end: 0x069B + mb_functioncode: 0x03 +parameters: + - group: Solar + items: + - name: PV1 Power + class: power + state_class: measurement + uom: W + scale: 10 + rule: 1 + registers: [0x0586] + icon: mdi:solar-power + - name: PV2 Power + class: power + state_class: measurement + uom: W + scale: 10 + rule: 1 + registers: [0x0589] + icon: mdi:solar-power + - name: PV1 Voltage + class: voltage + state_class: measurement + uom: V + scale: 0.1 + rule: 1 + registers: [0x0584] + icon: mdi:solar-power + - name: PV2 Voltage + class: voltage + state_class: measurement + uom: V + scale: 0.1 + rule: 1 + registers: [0x0587] + icon: mdi:solar-power + - name: PV1 Current + class: current + state_class: measurement + uom: A + scale: 0.01 + rule: 1 + registers: [0x0585] + icon: mdi:solar-power + - name: PV2 Current + class: current + state_class: measurement + uom: A + scale: 0.01 + rule: 1 + registers: [0x0588] + icon: mdi:solar-power + - name: Daily Production + class: energy + state_class: total_increasing + uom: kWh + scale: 0.01 + rule: 3 + registers: [0x0685, 0x0684] + icon: mdi:solar-power + - name: Total Production + class: energy + state_class: total_increasing + uom: kWh + scale: 0.1 + rule: 3 + registers: [0x0687, 0x0686] + icon: mdi:solar-power + - group: Output + items: + - name: Output active power + class: power + state_class: measurement + uom: W + scale: 10 + rule: 2 + registers: [0x0485] + icon: mdi:home-lightning-bolt + - name: Output reactive power + class: '' + state_class: measurement + uom: kVar + scale: 0.01 + rule: 2 + registers: [0x0486] + icon: mdi:home-lightning-bolt + - name: Grid frequency + class: frequency + state_class: measurement + uom: Hz + scale: 0.01 + rule: 1 + registers: [0x0484] + icon: mdi:home-lightning-bolt + - name: L1 Voltage + class: voltage + state_class: measurement + uom: V + scale: 0.1 + rule: 1 + registers: [0x048D] + icon: mdi:home-lightning-bolt + - name: L1 Current + class: current + state_class: measurement + uom: A + scale: 0.01 + rule: 1 + registers: [0x048E] + icon: mdi:home-lightning-bolt + - group: Inverter + items: + - name: Inverter status + class: '' + state_class: '' + uom: '' + scale: 1 + rule: 1 + registers: [0x0404] + lookup: + - key: 0 + value: Stand-by + - key: 1 + value: Self-checking + - key: 2 + value: Normal + - key: 3 + value: Emergency Power Supply + - key: 4 + value: Recoverable Fault + - key: 5 + value: Permanent Fault + - key: 6 + value: Upgrade + - key: 7 + value: Self-charging + - key: 8 + value: SVG + - key: 9 + value: PID + icon: mdi:wrench + - name: Inverter inner temperature + class: temperature + state_class: measurement + uom: °C + scale: 1 + rule: 2 + registers: [0x0418] + icon: mdi:thermometer + - name: Inverter radiator temperature + class: temperature + state_class: measurement + uom: °C + scale: 1 + rule: 2 + registers: [0x041A] + icon: mdi:thermometer + - group: Fault bitfields + items: + - name: Fault 1 + class: '' + state_class: '' + uom: '' + scale: 1 + rule: 1 + registers: [0x0405] + lookup: + - key: 0 + value: No error + icon: mdi:wrench + - name: Fault 2 + class: '' + state_class: '' + uom: '' + scale: 1 + rule: 1 + registers: [0x0406] + lookup: + - key: 0 + value: No error + icon: mdi:wrench + - name: Fault 3 + class: '' + state_class: '' + uom: '' + scale: 1 + rule: 1 + registers: [0x0407] + lookup: + - key: 0 + value: No error + icon: mdi:wrench + - name: Fault 4 + class: '' + state_class: '' + uom: '' + scale: 1 + rule: 1 + registers: [0x0408] + lookup: + - key: 0 + value: No error + icon: mdi:wrench + - name: Fault 5 + class: '' + state_class: '' + uom: '' + scale: 1 + rule: 1 + registers: [0x0409] + lookup: + - key: 0 + value: No error + icon: mdi:wrench + - name: Fault 6 + class: '' + state_class: '' + uom: '' + scale: 1 + rule: 1 + registers: [0x040A] + lookup: + - key: 0 + value: No error + icon: mdi:wrench + - name: Fault 7 + class: '' + state_class: '' + uom: '' + scale: 1 + rule: 1 + registers: [0x040B] + lookup: + - key: 0 + value: No error + icon: mdi:wrench + - name: Fault 8 + class: '' + state_class: '' + uom: '' + scale: 1 + rule: 1 + registers: [0x040C] + lookup: + - key: 0 + value: No error + icon: mdi:wrench + - name: Fault 9 + class: '' + state_class: '' + uom: '' + scale: 1 + rule: 1 + registers: [0x040D] + lookup: + - key: 0 + value: No error + icon: mdi:wrench + - name: Fault 10 + class: '' + state_class: '' + uom: '' + scale: 1 + rule: 1 + registers: [0x040E] + lookup: + - key: 0 + value: No error + icon: mdi:wrench + - name: Fault 11 + class: '' + state_class: '' + uom: '' + scale: 1 + rule: 1 + registers: [0x040F] + lookup: + - key: 0 + value: No error + icon: mdi:wrench + - name: Fault 12 + class: '' + state_class: '' + uom: '' + scale: 1 + rule: 1 + registers: [0x0410] + lookup: + - key: 0 + value: No error + icon: mdi:wrench diff --git a/tests/test_sofar_4ktlm_g3.py b/tests/test_sofar_4ktlm_g3.py new file mode 100644 index 0000000..4173d21 --- /dev/null +++ b/tests/test_sofar_4ktlm_g3.py @@ -0,0 +1,94 @@ +"""Decode representative G3 blocks with the integration's actual parser. + +These are synthetic samples; no device serials or household telemetry are used. +Run with: python -m unittest discover -s tests -v (requires PyYAML). +""" +import importlib.util +from pathlib import Path +import unittest + +import yaml + + +COMPONENT = Path(__file__).resolve().parents[1] / 'custom_components' / 'solarman' +PROFILE = 'sofar_4ktlm-g3.yaml' + + +def load_module(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +ParameterParser = load_module('solarman_profile_parser', COMPONENT / 'parser.py').ParameterParser + + +class Sofar4KTLMG3Test(unittest.TestCase): + def setUp(self): + with (COMPONENT / 'inverter_definitions' / PROFILE).open(encoding='utf-8') as source: + self.profile = yaml.safe_load(source) + + def decode(self, registers): + parser = ParameterParser(self.profile) + for request in self.profile['requests']: + start, end = request['start'], request['end'] + block = [registers.get(address, 0) for address in range(start, end + 1)] + parser.parse(block, start, len(block)) + return parser.get_result() + + def test_complete_aligned_requests_and_register_coverage(self): + requests = self.profile['requests'] + self.assertEqual( + [(r['start'], r['end'], r['mb_functioncode']) for r in requests], + [(0x0400, 0x042E, 3), (0x0480, 0x04AF, 3), (0x0580, 0x0589, 3), (0x0680, 0x069B, 3)], + ) + sensors = ParameterParser(self.profile).get_sensors() + names = [sensor['name'] for sensor in sensors] + self.assertEqual(len(names), len(set(names))) + for sensor in sensors: + with self.subTest(sensor=sensor['name']): + self.assertTrue(any(all(r['start'] <= address <= r['end'] for address in sensor['registers']) for r in requests)) + self.assertNotIn('L2 Voltage', names) + self.assertNotIn('L3 Voltage', names) + self.assertNotIn('Inverter module temperature', names) + + def test_two_mppt_single_phase_generation_and_energy_word_order(self): + decoded = self.decode({ + 0x0404: 2, 0x0418: 42, 0x041A: 33, + 0x0484: 6000, 0x0485: 97, 0x0486: 0, 0x048D: 2200, 0x048E: 441, + 0x0584: 2600, 0x0585: 200, 0x0586: 52, + 0x0587: 2590, 0x0588: 198, 0x0589: 51, + 0x0684: 0, 0x0685: 321, 0x0686: 1, 0x0687: 0x2345, + }) + self.assertEqual(decoded['Inverter status'], 'Normal') + self.assertEqual(decoded['PV1 Power'], 520) + self.assertEqual(decoded['PV2 Power'], 510) + self.assertEqual(decoded['PV1 Voltage'], 260) + self.assertAlmostEqual(decoded['PV2 Current'], 1.98) + self.assertEqual(decoded['Output active power'], 970) + self.assertEqual(decoded['Grid frequency'], 60) + self.assertEqual(decoded['L1 Voltage'], 220) + self.assertAlmostEqual(decoded['L1 Current'], 4.41) + self.assertEqual(decoded['Inverter inner temperature'], 42) + self.assertEqual(decoded['Inverter radiator temperature'], 33) + self.assertAlmostEqual(decoded['Daily Production'], 3.21) + self.assertAlmostEqual(decoded['Total Production'], 0x12345 * 0.1) + + def test_g3_fault_status_is_not_the_legacy_status_mapping(self): + self.assertEqual(self.decode({0x0404: 4})['Inverter status'], 'Recoverable Fault') + self.assertEqual(self.decode({0x0404: 5})['Inverter status'], 'Permanent Fault') + + def test_fault_bitfields_are_not_mislabelled_as_legacy_faults(self): + decoded = self.decode({0x0404: 2, 0x0405: 3, 0x0410: 0x8000}) + self.assertEqual(decoded['Fault 1'], 3) + self.assertEqual(decoded['Fault 2'], 'No error') + self.assertEqual(decoded['Fault 12'], 0x8000) + + def test_profile_is_available_in_config_flow_lookup_list(self): + const = load_module('solarman_profile_const', COMPONENT / 'const.py') + self.assertIn(PROFILE, const.LOOKUP_FILES) + + +if __name__ == '__main__': + unittest.main()