├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── feature-request.yml └── workflows │ ├── assets.yaml │ ├── butler.yaml │ └── ha.yaml ├── .gitignore ├── custom_components └── solarman │ ├── __init__.py │ ├── binary_sensor.py │ ├── button.py │ ├── common.py │ ├── config_flow.py │ ├── const.py │ ├── coordinator.py │ ├── datetime.py │ ├── device.py │ ├── discovery.py │ ├── entity.py │ ├── inverter_definitions │ ├── afore_2mppt.yaml │ ├── afore_BNTxxxKTL-2mppt.yaml │ ├── afore_hybrid.yaml │ ├── astro-energy_2mppt.yaml │ ├── chint_cps-scetl.yaml │ ├── deye_hybrid.yaml │ ├── deye_micro.yaml │ ├── deye_p3.yaml │ ├── deye_string.yaml │ ├── hinen_hybrid.yaml │ ├── invt_xd-tl.yaml │ ├── kstar_hybrid.yaml │ ├── megarevo_r-3h.yaml │ ├── pylontech_force.yaml │ ├── renon_ifl.yaml │ ├── sofar_g3.yaml │ ├── sofar_g3hyd.yaml │ ├── sofar_lsw3.yaml │ ├── sofar_wifikit.yaml │ ├── solarman_dtsd422-d3.yaml │ ├── solis_1p-5g.yaml │ ├── solis_3p-4g.yaml │ ├── solis_3p-5g.yaml │ ├── solis_hybrid.yaml │ ├── solis_s6-gr1p.yaml │ ├── srne_asf.yaml │ ├── swatten_sih-th.yaml │ └── tsun_tsol-ms.yaml │ ├── manifest.json │ ├── number.py │ ├── parser.py │ ├── provider.py │ ├── pysolarman │ ├── __init__.py │ ├── license │ ├── pysolarman.py │ └── umodbus │ │ ├── __init__.py │ │ ├── client │ │ ├── __init__.py │ │ ├── serial │ │ │ ├── __init__.py │ │ │ ├── redundancy_check.py │ │ │ └── rtu.py │ │ └── tcp.py │ │ ├── config.py │ │ ├── exceptions.py │ │ ├── functions.py │ │ ├── license │ │ ├── route.py │ │ ├── server │ │ ├── __init__.py │ │ ├── serial │ │ │ ├── __init__.py │ │ │ └── rtu.py │ │ └── tcp.py │ │ └── utils.py │ ├── select.py │ ├── sensor.py │ ├── services.py │ ├── services.yaml │ ├── switch.py │ ├── time.py │ └── translations │ ├── ca.json │ ├── cs.json │ ├── de.json │ ├── en.json │ ├── it.json │ ├── pl.json │ ├── pt-BR.json │ └── ua.json ├── hacs.json ├── license ├── readme.md └── tools ├── discovery.py ├── discovery_reply.py └── scheduler.py /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug report 2 | description: Create a report to help us improve 3 | labels: [bug] 4 | body: 5 | - type: textarea 6 | id: background 7 | attributes: 8 | label: Description 9 | description: Please share a clear and concise description of the problem. 10 | placeholder: Description 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: repro-steps 15 | attributes: 16 | label: Reproduction Steps 17 | description: | 18 | Please include minimal steps to reproduce the problem if possible. Attach or link a project if you have one, even if it is very simple. It helps to quickly evaluate and diagnose the issue and ensures any fix addresses your scenario. If you cannot attach a project, include the smallest possible code snippet, with steps to run it. If possible include text as text rather than screenshots (so it shows up in searches). 19 | placeholder: Minimal Reproduction 20 | validations: 21 | required: true 22 | - type: textarea 23 | id: log 24 | attributes: 25 | label: Log 26 | description: Please don't submit issue w/o debug log, and preferably from the time the problem occurred! 27 | placeholder: Attach the debug log as a file or using the 'code' formatting 28 | validations: 29 | required: true 30 | - type: input 31 | id: version 32 | attributes: 33 | label: Version 34 | placeholder: "24.12.22" 35 | validations: 36 | required: true 37 | - type: input 38 | id: version_core 39 | attributes: 40 | label: Home Assistant Core Version 41 | placeholder: "2024.12.5" 42 | validations: 43 | required: true 44 | - type: input 45 | id: version_haos 46 | attributes: 47 | label: Home Assistant Operating System Version 48 | placeholder: "14.1" 49 | validations: 50 | required: true 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: 💡 Feature request 2 | description: Suggest an idea for this project 3 | labels: [enhancement] 4 | body: 5 | - type: textarea 6 | id: relation 7 | attributes: 8 | label: Is your feature request related to a problem? 9 | description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 10 | placeholder: Description 11 | - type: textarea 12 | id: background 13 | attributes: 14 | label: Describe the solution you'd like 15 | description: A clear and concise description of what you want to happen. 16 | placeholder: Purpose 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: reason 21 | attributes: 22 | label: Describe alternatives you've considered 23 | description: A clear and concise description of any alternative solutions or features you've considered. 24 | placeholder: Alternative 25 | validations: 26 | required: true 27 | -------------------------------------------------------------------------------- /.github/workflows/assets.yaml: -------------------------------------------------------------------------------- 1 | name: Attach Assets 2 | on: 3 | release: 4 | types: 5 | - created 6 | env: 7 | name: solarman 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | submodules: true 17 | - name: Zip 18 | run: | 19 | cd ${{ github.workspace }}/custom_components/$name 20 | cp ../../license ./ 21 | zip -r ../$name.zip . -x "*.git*" "*.git/*" "*.github/*" 22 | - name: Release 23 | uses: softprops/action-gh-release@v1 24 | if: startsWith(github.ref, 'refs/tags/') 25 | with: 26 | files: ${{ github.workspace }}/custom_components/*.zip 27 | -------------------------------------------------------------------------------- /.github/workflows/butler.yaml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: 30 1 * * * 5 | jobs: 6 | close-issues: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | steps: 12 | - uses: actions/stale@v9 13 | with: 14 | days-before-issue-stale: 30 15 | days-before-issue-close: 14 16 | days-before-pr-stale: -1 17 | days-before-pr-close: -1 18 | stale-issue-label: stale 19 | close-issue-label: closed 20 | stale-issue-message: This issue is stale because it has been open for 30 days with no activity. 21 | close-issue-message: This issue was closed because it has been inactive for 14 days since being marked as stale. 22 | exempt-issue-labels: help wanted 23 | exempt-all-assignees: true 24 | repo-token: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/ha.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | on: 3 | push: 4 | pull_request: 5 | schedule: 6 | - cron: 0 0 * * * 7 | workflow_dispatch: 8 | jobs: 9 | hassfest: 10 | name: with hassfest 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: home-assistant/actions/hassfest@master 15 | hacs: 16 | name: with hacs 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: hacs/action@main 21 | with: 22 | category: integration 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # HACS 7 | custom_components/solarman/inverter_definitions/custom/ 8 | -------------------------------------------------------------------------------- /custom_components/solarman/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from functools import partial 6 | 7 | from homeassistant.const import Platform 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.helpers import config_validation as cv, device_registry as dr 10 | from homeassistant.helpers.typing import ConfigType 11 | from homeassistant.helpers.entity_registry import async_migrate_entries 12 | 13 | from .const import * 14 | from .common import * 15 | from .config_flow import ConfigFlowHandler 16 | from .provider import ConfigurationProvider 17 | from .coordinator import Device, Coordinator 18 | from .entity import SolarmanConfigEntry, migrate_unique_ids 19 | from .services import async_register 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | _PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SWITCH, Platform.BUTTON, Platform.SELECT, Platform.DATETIME, Platform.TIME] 24 | 25 | CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) 26 | 27 | async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: 28 | _LOGGER.debug(f"async_setup") 29 | 30 | async_register(hass) 31 | 32 | return True 33 | 34 | async def async_setup_entry(hass: HomeAssistant, config_entry: SolarmanConfigEntry) -> bool: 35 | _LOGGER.debug(f"async_setup_entry({config_entry.as_dict()})") 36 | 37 | config = ConfigurationProvider(hass, config_entry) 38 | config_entry.runtime_data = Coordinator(hass, Device(config)) 39 | 40 | # Fetch initial data so we have data when entities subscribe. 41 | # 42 | # If the refresh fails, async_config_entry_first_refresh will 43 | # raise ConfigEntryNotReady and setup will try again later. 44 | # 45 | # If you do not want to retry setup on failure, use 46 | # config_entry.runtime_data.async_refresh() instead. 47 | # 48 | _LOGGER.debug(f"async_setup_entry: config_entry.runtime_data.async_config_entry_first_refresh") 49 | 50 | await config_entry.runtime_data.async_config_entry_first_refresh() 51 | 52 | # Migrations 53 | # 54 | _LOGGER.debug(f"async_setup_entry: async_migrate_entries") 55 | 56 | await async_migrate_entries(hass, config_entry.entry_id, partial(migrate_unique_ids, config_entry)) 57 | 58 | # Forward setup 59 | # 60 | _LOGGER.debug(f"async_setup_entry: hass.config_entries.async_forward_entry_setups: {_PLATFORMS}") 61 | 62 | await hass.config_entries.async_forward_entry_setups(config_entry, _PLATFORMS) 63 | 64 | # Add update listener 65 | # 66 | _LOGGER.debug(f"async_setup_entry: config_entry.add_update_listener(async_update_listener)") 67 | 68 | async def async_update_listener(hass: HomeAssistant, config_entry: SolarmanConfigEntry) -> None: 69 | _LOGGER.debug(f"async_update_listener({config_entry.as_dict()})") 70 | await hass.config_entries.async_reload(config_entry.entry_id) 71 | 72 | config_entry.async_on_unload(config_entry.add_update_listener(async_update_listener)) 73 | 74 | return True 75 | 76 | async def async_unload_entry(hass: HomeAssistant, config_entry: SolarmanConfigEntry) -> bool: 77 | _LOGGER.debug(f"async_unload_entry({config_entry.as_dict()})") 78 | 79 | # Forward unload 80 | # 81 | _LOGGER.debug(f"async_unload_entry: hass.config_entries.async_unload_platforms: {_PLATFORMS}") 82 | 83 | return await hass.config_entries.async_unload_platforms(config_entry, _PLATFORMS) 84 | 85 | async def async_migrate_entry(hass: HomeAssistant, config_entry: SolarmanConfigEntry) -> bool: 86 | _LOGGER.debug(f"async_migrate_entry({config_entry.as_dict()})") 87 | 88 | #if config_entry.minor_version > 1: 89 | # return False 90 | 91 | _LOGGER.info("Migrating configuration from version %s.%s", config_entry.version, config_entry.minor_version) 92 | 93 | if (new_data := {**config_entry.data}) and (new_options := {**config_entry.options}): 94 | bulk_migrate(new_data, new_data, OLD_) 95 | bulk_migrate(new_options, new_options, OLD_) 96 | bulk_inherit(new_options.setdefault(CONF_ADDITIONAL_OPTIONS, {}), new_options, CONF_BATTERY_NOMINAL_VOLTAGE, CONF_BATTERY_LIFE_CYCLE_RATING) 97 | if new_options.get("sn", new_data.get("sn", 1)) == 0: 98 | new_options[CONF_TRANSPORT] = "modbus_tcp" 99 | bulk_safe_delete(new_data, OLD_) 100 | bulk_safe_delete(new_options, OLD_ | to_dict(CONF_BATTERY_NOMINAL_VOLTAGE, CONF_BATTERY_LIFE_CYCLE_RATING)) 101 | 102 | if a := new_options.get(CONF_ADDITIONAL_OPTIONS): 103 | if isinstance(m := a.get(CONF_MOD), bool): 104 | m = int(m) 105 | else: 106 | del new_options[CONF_ADDITIONAL_OPTIONS] 107 | 108 | hass.config_entries.async_update_entry(config_entry, unique_id = None, data = new_data, options = new_options, minor_version = ConfigFlowHandler.MINOR_VERSION, version = ConfigFlowHandler.VERSION) 109 | 110 | _LOGGER.info("Migration to configuration version %s.%s was successful", config_entry.version, config_entry.minor_version) 111 | 112 | return True 113 | 114 | async def async_remove_config_entry_device(hass: HomeAssistant, config_entry: SolarmanConfigEntry, device_entry: dr.DeviceEntry) -> bool: 115 | _LOGGER.debug(f"async_remove_config_entry_device({config_entry.as_dict()}, {device_entry})") 116 | 117 | return not any(identifier for identifier in device_entry.identifiers if identifier[0] == DOMAIN and identifier[1] == config_entry.entry_id or identifier[1] == config_entry.runtime_data.device.modbus.serial) 118 | -------------------------------------------------------------------------------- /custom_components/solarman/binary_sensor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from typing import Any 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.const import EntityCategory 9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 10 | from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass 11 | 12 | from .const import * 13 | from .common import * 14 | from .services import * 15 | from .entity import SolarmanConfigEntry, create_entity, SolarmanEntity 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | _PLATFORM = get_current_file_name(__name__) 20 | 21 | async def async_setup_entry(_: HomeAssistant, config_entry: SolarmanConfigEntry, async_add_entities: AddEntitiesCallback) -> bool: 22 | _LOGGER.debug(f"async_setup_entry: {config_entry.options}") 23 | 24 | async_add_entities(create_entity(lambda x: SolarmanBinarySensorEntity(config_entry.runtime_data, x), d) for d in postprocess_descriptions(config_entry.runtime_data, _PLATFORM)) 25 | 26 | async_add_entities([create_entity(lambda _: SolarmanConnectionSensor(config_entry.runtime_data), None)]) 27 | 28 | return True 29 | 30 | async def async_unload_entry(_: HomeAssistant, config_entry: SolarmanConfigEntry) -> bool: 31 | _LOGGER.debug(f"async_unload_entry: {config_entry.options}") 32 | 33 | return True 34 | 35 | class SolarmanBinarySensorEntity(SolarmanEntity, BinarySensorEntity): 36 | def __init__(self, coordinator, sensor): 37 | SolarmanEntity.__init__(self, coordinator, sensor) 38 | self._sensor_inverted = False 39 | if "inverted" in sensor and (inverted := sensor["inverted"]): 40 | self._sensor_inverted = inverted 41 | 42 | @property 43 | def is_on(self) -> bool | None: 44 | return (self._attr_state != 0) if not self._sensor_inverted else (self._attr_state == 0) 45 | 46 | class SolarmanConnectionSensor(SolarmanBinarySensorEntity): 47 | def __init__(self, coordinator): 48 | super().__init__(coordinator, {"key": "connection_binary_sensor", "name": "Connection"}) 49 | self._attr_device_class = BinarySensorDeviceClass.CONNECTIVITY 50 | self._attr_entity_category = EntityCategory.DIAGNOSTIC 51 | self._attr_state = -1 52 | 53 | @property 54 | def available(self) -> bool: 55 | return self._attr_state is not None 56 | 57 | @property 58 | def is_on(self) -> bool | None: 59 | return self._attr_state > 0 60 | 61 | def update(self): 62 | self.set_state(self.coordinator.device.state.value) 63 | self._attr_extra_state_attributes["updated"] = self.coordinator.device.state.updated.timestamp() 64 | -------------------------------------------------------------------------------- /custom_components/solarman/button.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from typing import Any 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.const import STATE_OFF, STATE_ON 9 | from homeassistant.components.button import ButtonEntity, ButtonDeviceClass, ButtonEntityDescription 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | 12 | from .const import * 13 | from .common import * 14 | from .services import * 15 | from .entity import SolarmanConfigEntry, create_entity, SolarmanWritableEntity 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | _PLATFORM = get_current_file_name(__name__) 20 | 21 | async def async_setup_entry(_: HomeAssistant, config_entry: SolarmanConfigEntry, async_add_entities: AddEntitiesCallback) -> bool: 22 | _LOGGER.debug(f"async_setup_entry: {config_entry.options}") 23 | 24 | async_add_entities(create_entity(lambda x: SolarmanButtonEntity(config_entry.runtime_data, x), d) for d in postprocess_descriptions(config_entry.runtime_data, _PLATFORM)) 25 | 26 | return True 27 | 28 | async def async_unload_entry(_: HomeAssistant, config_entry: SolarmanConfigEntry) -> bool: 29 | _LOGGER.debug(f"async_unload_entry: {config_entry.options}") 30 | 31 | return True 32 | 33 | class SolarmanButtonEntity(SolarmanWritableEntity, ButtonEntity): 34 | def __init__(self, coordinator, sensor): 35 | SolarmanWritableEntity.__init__(self, coordinator, sensor) 36 | 37 | self._value = 1 38 | self._value_bit = None 39 | if "value" in sensor and (value := sensor["value"]) and not isinstance(value, int): 40 | if True in value: 41 | self._value = value[True] 42 | if "on" in value: 43 | self._value = value["on"] 44 | if "bit" in value: 45 | self._value_bit = value["bit"] 46 | 47 | def _to_native_value(self, value: int) -> int: 48 | if self._value_bit: 49 | return (self._get_attr_native_value & ~(1 << self._value_bit)) | (value << self._value_bit) 50 | return value 51 | 52 | async def async_press(self) -> None: 53 | """Handle the button press.""" 54 | await self.write(self._to_native_value(self._value)) 55 | -------------------------------------------------------------------------------- /custom_components/solarman/config_flow.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import voluptuous as vol 5 | 6 | from typing import Any 7 | from socket import getaddrinfo, herror, gaierror, timeout 8 | 9 | from homeassistant.const import CONF_NAME 10 | from homeassistant.core import HomeAssistant, callback 11 | from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow 12 | from homeassistant.data_entry_flow import section, AbortFlow 13 | from homeassistant.helpers import config_validation as cv, device_registry as dr 14 | from homeassistant.helpers.selector import selector 15 | from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo 16 | 17 | from .const import * 18 | from .common import * 19 | from .discovery import Discovery 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | DATA_SCHEMA = { 24 | vol.Required(CONF_NAME, default = DEFAULT_[CONF_NAME]): str 25 | } 26 | 27 | OPTS_SCHEMA = { 28 | vol.Required(CONF_HOST, default = DEFAULT_[CONF_HOST], description = {SUGGESTED_VALUE: DEFAULT_[CONF_HOST]}): str, 29 | vol.Optional(CONF_PORT, default = DEFAULT_[CONF_PORT], description = {SUGGESTED_VALUE: DEFAULT_[CONF_PORT]}): cv.port, 30 | vol.Optional(CONF_TRANSPORT, default = DEFAULT_[CONF_TRANSPORT], description = {SUGGESTED_VALUE: DEFAULT_[CONF_TRANSPORT]}): selector({ 31 | "select": { 32 | "mode": "dropdown", 33 | "options": ["tcp", "modbus_tcp"], 34 | "translation_key": "transport" 35 | } 36 | }), 37 | vol.Optional(CONF_LOOKUP_FILE, default = DEFAULT_[CONF_LOOKUP_FILE], description = {SUGGESTED_VALUE: DEFAULT_[CONF_LOOKUP_FILE]}): str, 38 | vol.Required(CONF_ADDITIONAL_OPTIONS): section( 39 | vol.Schema( 40 | { 41 | vol.Optional(CONF_MOD, default = DEFAULT_[CONF_MOD], description = {SUGGESTED_VALUE: DEFAULT_[CONF_MOD]}): vol.All(vol.Coerce(int), vol.Range(min = 0, max = 2)), 42 | vol.Optional(CONF_MPPT, default = DEFAULT_[CONF_MPPT], description = {SUGGESTED_VALUE: DEFAULT_[CONF_MPPT]}): vol.All(vol.Coerce(int), vol.Range(min = 1, max = 12)), 43 | vol.Optional(CONF_PHASE, default = DEFAULT_[CONF_PHASE], description = {SUGGESTED_VALUE: DEFAULT_[CONF_PHASE]}): vol.All(vol.Coerce(int), vol.Range(min = 1, max = 3)), 44 | vol.Optional(CONF_PACK, default = DEFAULT_[CONF_PACK], description = {SUGGESTED_VALUE: DEFAULT_[CONF_PACK]}): vol.All(vol.Coerce(int), vol.Range(min = -1, max = 20)), 45 | vol.Optional(CONF_BATTERY_NOMINAL_VOLTAGE, default = DEFAULT_[CONF_BATTERY_NOMINAL_VOLTAGE], description = {SUGGESTED_VALUE: DEFAULT_[CONF_BATTERY_NOMINAL_VOLTAGE]}): cv.positive_int, 46 | vol.Optional(CONF_BATTERY_LIFE_CYCLE_RATING, default = DEFAULT_[CONF_BATTERY_LIFE_CYCLE_RATING], description = {SUGGESTED_VALUE: DEFAULT_[CONF_BATTERY_LIFE_CYCLE_RATING]}): cv.positive_int, 47 | vol.Optional(CONF_MB_SLAVE_ID, default = DEFAULT_[CONF_MB_SLAVE_ID], description = {SUGGESTED_VALUE: DEFAULT_[CONF_MB_SLAVE_ID]}): cv.positive_int 48 | } 49 | ), 50 | {"collapsed": True} 51 | ) 52 | } 53 | 54 | async def data_schema(hass: HomeAssistant, data_schema: dict[str, Any]) -> vol.Schema: 55 | lookup_files = [DEFAULT_[CONF_LOOKUP_FILE]] + await async_listdir(hass.config.path(LOOKUP_DIRECTORY_PATH)) + await async_listdir(hass.config.path(LOOKUP_CUSTOM_DIRECTORY_PATH), "custom/") 56 | _LOGGER.debug(f"step_user_data_schema: {LOOKUP_DIRECTORY_PATH}: {lookup_files}") 57 | data_schema[CONF_LOOKUP_FILE] = vol.In(lookup_files) 58 | _LOGGER.debug(f"step_user_data_schema: data_schema: {data_schema}") 59 | return vol.Schema(data_schema) 60 | 61 | def validate_connection(user_input: dict[str, Any], errors: dict) -> dict[str, Any]: 62 | _LOGGER.debug(f"validate_connection: {user_input}") 63 | 64 | try: 65 | if host := user_input.get(CONF_HOST, IP_ANY): 66 | getaddrinfo(host, user_input.get(CONF_PORT, DEFAULT_[CONF_PORT]), family = 0, type = 0, proto = 0, flags = 0) 67 | except herror: 68 | errors["base"] = "invalid_host" 69 | except (gaierror, timeout): 70 | errors["base"] = "cannot_connect" 71 | except Exception as e: 72 | _LOGGER.exception(f"validate_connection: {e!r}") 73 | errors["base"] = "unknown" 74 | else: 75 | _LOGGER.debug(f"validate_connection: validation passed: {user_input}") 76 | return True 77 | 78 | return False 79 | 80 | def remove_defaults(user_input: dict[str, Any]): 81 | for k in list(user_input.keys()): 82 | if k == CONF_ADDITIONAL_OPTIONS: 83 | for l in list(user_input[k].keys()): 84 | if user_input[k][l] == DEFAULT_.get(l): 85 | del user_input[k][l] 86 | if not user_input[k]: 87 | del user_input[k] 88 | elif user_input[k] == DEFAULT_.get(k): 89 | del user_input[k] 90 | return user_input 91 | 92 | class ConfigFlowHandler(ConfigFlow, domain = DOMAIN): 93 | MINOR_VERSION = 0 94 | VERSION = 2 95 | 96 | async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> ConfigFlowResult: 97 | _LOGGER.debug(f"ConfigFlowHandler.async_step_dhcp: {discovery_info}") 98 | if (device := dr.async_get(self.hass).async_get_device(connections = {(dr.CONNECTION_NETWORK_MAC, dr.format_mac(discovery_info.macaddress))})) is not None: 99 | for entry in self._async_current_entries(): 100 | if entry.entry_id in device.config_entries and entry.options.get(CONF_HOST) != discovery_info.ip: 101 | self.hass.config_entries.async_update_entry(entry, options = entry.options | {CONF_HOST: discovery_info.ip}) 102 | self.hass.async_create_task(self.hass.config_entries.async_reload(entry.entry_id)) 103 | return self.async_abort(reason = "already_configured") 104 | self._async_abort_entries_match({ CONF_HOST: discovery_info.ip }) 105 | await self._async_handle_discovery_without_unique_id() 106 | return await self.async_step_user() 107 | 108 | async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 109 | _LOGGER.debug(f"ConfigFlowHandler.async_step_user: {user_input}") 110 | if user_input is None: 111 | name = None 112 | ip = None 113 | if (discovered := await Discovery(self.hass).discover()): 114 | for s, v in discovered.items(): 115 | try: 116 | self._async_abort_entries_match({ CONF_HOST: v["ip"] }) 117 | ip = v["ip"] 118 | break 119 | except: 120 | continue 121 | for i in range(0, 1000): 122 | try: 123 | for entry in self._async_current_entries(include_ignore = False): 124 | if entry.title == (name := ' '.join(filter(None, (DEFAULT_[CONF_NAME], None if not i else str(i if i != 1 else 2))))): 125 | raise AbortFlow("already_configured") 126 | break 127 | except: 128 | continue 129 | else: 130 | name = None 131 | return self.async_show_form(step_id = "user", data_schema = self.add_suggested_values_to_schema(await data_schema(self.hass, DATA_SCHEMA | OPTS_SCHEMA), {CONF_NAME: name, CONF_HOST: ip})) 132 | 133 | errors = {} 134 | 135 | if validate_connection(user_input, errors): 136 | await self.async_set_unique_id(None) 137 | self._abort_if_unique_id_configured() #self._abort_if_unique_id_configured(updates={CONF_HOST: url.host}) 138 | return self.async_create_entry(title = user_input[CONF_NAME], data = {}, options = remove_defaults(filter_by_keys(user_input, OPTS_SCHEMA))) 139 | 140 | _LOGGER.debug(f"ConfigFlowHandler.async_step_user: connection validation failed: {user_input}") 141 | 142 | return self.async_show_form(step_id = "user", data_schema = self.add_suggested_values_to_schema(await data_schema(self.hass, DATA_SCHEMA | OPTS_SCHEMA), user_input), errors = errors) 143 | 144 | @staticmethod 145 | @callback 146 | def async_get_options_flow(entry: ConfigEntry) -> OptionsFlowHandler: 147 | _LOGGER.debug(f"ConfigFlowHandler.async_get_options_flow: {entry}") 148 | return OptionsFlowHandler(entry) 149 | 150 | class OptionsFlowHandler(OptionsFlow): 151 | def __init__(self, entry: ConfigEntry) -> None: 152 | _LOGGER.debug(f"OptionsFlowHandler.__init__: {entry}") 153 | self.entry = entry 154 | 155 | async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 156 | _LOGGER.debug(f"OptionsFlowHandler.async_step_init: user_input: {user_input}, current: {self.entry.options}") 157 | if user_input is None: 158 | return self.async_show_form(step_id = "init", data_schema = self.add_suggested_values_to_schema(await data_schema(self.hass, OPTS_SCHEMA), self.entry.options)) 159 | 160 | errors = {} 161 | 162 | if validate_connection(user_input, errors): 163 | return self.async_create_entry(data = remove_defaults(user_input)) 164 | 165 | _LOGGER.debug(f"OptionsFlowHandler.async_step_init: connection validation failed: {user_input}") 166 | 167 | return self.async_show_form(step_id = "init", data_schema = self.add_suggested_values_to_schema(await data_schema(self.hass, OPTS_SCHEMA), user_input), errors = errors) 168 | -------------------------------------------------------------------------------- /custom_components/solarman/const.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta as td 2 | 3 | DOMAIN = "solarman" 4 | 5 | IP_BROADCAST = "" 6 | IP_ANY = "0.0.0.0" 7 | 8 | PORT_ANY = 0 9 | 10 | DISCOVERY_PORT = 48899 11 | DISCOVERY_TIMEOUT = .5 12 | DISCOVERY_MESSAGE = ["WIFIKIT-214028-READ".encode(), "HF-A11ASSISTHREAD".encode()] 13 | 14 | COMPONENTS_DIRECTORY = "custom_components" 15 | 16 | LOOKUP_DIRECTORY = "inverter_definitions" 17 | LOOKUP_DIRECTORY_PATH = f"{COMPONENTS_DIRECTORY}/{DOMAIN}/{LOOKUP_DIRECTORY}/" 18 | LOOKUP_CUSTOM_DIRECTORY_PATH = f"{COMPONENTS_DIRECTORY}/{DOMAIN}/{LOOKUP_DIRECTORY}/custom/" 19 | 20 | CONF_HOST = "host" 21 | CONF_PORT = "port" 22 | CONF_TRANSPORT = "transport" 23 | CONF_LOOKUP_FILE = "lookup_file" 24 | CONF_ADDITIONAL_OPTIONS = "additional_options" 25 | CONF_MOD = "mod" 26 | CONF_MPPT = "mppt" 27 | CONF_PHASE = "phase" 28 | CONF_PACK = "pack" 29 | CONF_BATTERY_NOMINAL_VOLTAGE = "battery_nominal_voltage" 30 | CONF_BATTERY_LIFE_CYCLE_RATING = "battery_life_cycle_rating" 31 | CONF_MB_SLAVE_ID = "mb_slave_id" 32 | 33 | OLD_ = { "name": "name", "serial": "inverter_serial", "sn": "serial", "sn": "sn", CONF_HOST: "inverter_host", CONF_PORT: "inverter_port" } 34 | 35 | SUGGESTED_VALUE = "suggested_value" 36 | UPDATE_INTERVAL = "update_interval" 37 | IS_SINGLE_CODE = "is_single_code" 38 | REGISTERS_CODE = "registers_code" 39 | REGISTERS_MIN_SPAN = "registers_min_span" 40 | REGISTERS_MAX_SIZE = "registers_max_size" 41 | DIGITS = "digits" 42 | 43 | DEFAULT_ = { 44 | "name": "Inverter", 45 | CONF_HOST: "", 46 | CONF_PORT: 8899, 47 | CONF_TRANSPORT: "tcp", 48 | CONF_MB_SLAVE_ID: 1, 49 | CONF_LOOKUP_FILE: "Auto", 50 | CONF_MOD: 0, 51 | CONF_MPPT: 4, 52 | CONF_PHASE: 3, 53 | CONF_PACK: -1, 54 | CONF_BATTERY_NOMINAL_VOLTAGE: 48, 55 | CONF_BATTERY_LIFE_CYCLE_RATING: 6000, 56 | UPDATE_INTERVAL: 60, 57 | IS_SINGLE_CODE: False, 58 | REGISTERS_CODE: 0x03, 59 | REGISTERS_MIN_SPAN: 25, 60 | REGISTERS_MAX_SIZE: 125, 61 | DIGITS: 6 62 | } 63 | 64 | AUTODETECTION_DEYE_STRING = ((0x0002, 0x0200), "deye_string.yaml") 65 | AUTODETECTION_DEYE_P1 = ((0x0003, 0x0300), "deye_hybrid.yaml") 66 | AUTODETECTION_DEYE_MICRO = ((0x0004, 0x0400), "deye_micro.yaml") 67 | AUTODETECTION_DEYE_4P3 = ((0x0005, 0x0500), "deye_p3.yaml") 68 | AUTODETECTION_DEYE_1P3 = ((0x0006, 0x0007, 0x0600, 0x0008, 0x0601), "deye_p3.yaml") 69 | AUTODETECTION_REDIRECT = [DEFAULT_[CONF_LOOKUP_FILE], AUTODETECTION_DEYE_STRING[1], "deye_p1.yaml", AUTODETECTION_DEYE_P1[1], AUTODETECTION_DEYE_MICRO[1], "deye_4mppt.yaml", "deye_2mppt.yaml", AUTODETECTION_DEYE_4P3[1], "deye_sg04lp3.yaml", "deye_sg01hp3.yaml"] 70 | AUTODETECTION_CODE_DEYE = 0x03 71 | AUTODETECTION_REGISTERS_DEYE = (0x0000, 0x0016) 72 | AUTODETECTION_REQUEST_DEYE = (AUTODETECTION_CODE_DEYE, *AUTODETECTION_REGISTERS_DEYE) 73 | AUTODETECTION_DEVICE_DEYE = (AUTODETECTION_CODE_DEYE, AUTODETECTION_REGISTERS_DEYE[0]) 74 | AUTODETECTION_TYPE_DEYE = (AUTODETECTION_CODE_DEYE, 0x0008) 75 | AUTODETECTION_DEYE = { AUTODETECTION_DEYE_STRING[0]: (AUTODETECTION_DEYE_STRING[1], 0, 0x12), AUTODETECTION_DEYE_P1[0]: (AUTODETECTION_DEYE_P1[1], 0, 0x12), AUTODETECTION_DEYE_MICRO[0]: (AUTODETECTION_DEYE_MICRO[1], 0, 0x12), AUTODETECTION_DEYE_4P3[0]: (AUTODETECTION_DEYE_4P3[1], 0, 0x16), AUTODETECTION_DEYE_1P3[0]: (AUTODETECTION_DEYE_1P3[1], 1, 0x16) } 76 | AUTODETECTION_BATTERY_REGISTERS_DEYE = (0x2712, 0x2712) 77 | AUTODETECTION_BATTERY_REQUEST_DEYE = (AUTODETECTION_CODE_DEYE, *AUTODETECTION_BATTERY_REGISTERS_DEYE) 78 | AUTODETECTION_BATTERY_NUMBER_DEYE = (AUTODETECTION_CODE_DEYE, AUTODETECTION_BATTERY_REGISTERS_DEYE[0]) 79 | 80 | PROFILE_REDIRECT = { "sofar_hyd3k-6k-es.yaml": "sofar_wifikit.yaml:mod=1", "hyd-zss-hp-3k-6k.yaml": "sofar_g3.yaml:pack=1", "solis_1p8k-5g.yaml": "solis_1p-5g.yaml", "solis_3p-4g+.yaml": "solis_3p-4g.yaml", "sofar_hyd-es.yaml": "sofar_wifikit.yaml:mod=1", "sofar_tlx-g3.yaml": "sofar_g3.yaml", "zcs_azzurro-1ph-tl-v3.yaml": "sofar_lsw3.yaml:mppt=1&l=1", "zcs_azzurro-hyd-zss-hp.yaml": "sofar_g3.yaml:pack=1", "zcs_azzurro-ktl-v3.yaml": "sofar_g3.yaml", "pylontech_Force-H.yaml": "pylontech_force.yaml:mod=1" } 81 | 82 | PARAM_ = { CONF_MOD: CONF_MOD, CONF_MPPT: CONF_MPPT, CONF_PHASE: "l", CONF_PACK: CONF_PACK } 83 | 84 | # Data are requsted in most cases in different invervals: 85 | # - from 5s for power sensors for example (deye_sg04lp3, ..) 86 | # - up to 5m (deye_sg04lp3, ..) or 10m (kstar_hybrid, ..) for static valus like Serial Number, etc. 87 | # 88 | # Changing of this value does not affects the amount of stored data beyond recorder's retention period 89 | # On the contrary changing this value can break: 90 | # - Request scheduling according "update_interval" properties set in profiles 91 | # - Behavior of services 92 | # - Configuration flow 93 | # 94 | TIMINGS_INTERVAL = 5 95 | TIMINGS_INTERVAL_SCALE = 1 96 | TIMINGS_UPDATE_INTERVAL = td(seconds = TIMINGS_INTERVAL * TIMINGS_INTERVAL_SCALE) 97 | 98 | REQUEST_UPDATE_INTERVAL = UPDATE_INTERVAL 99 | REQUEST_MIN_SPAN = "min_span" 100 | REQUEST_MAX_SIZE = "max_size" 101 | REQUEST_CODE = "code" 102 | REQUEST_CODE_ALT = "mb_functioncode" 103 | REQUEST_START = "start" 104 | REQUEST_END = "end" 105 | REQUEST_COUNT = "count" 106 | 107 | SERVICES_PARAM_DEVICE = "device" 108 | SERVICES_PARAM_ADDRESS = "address" 109 | SERVICES_PARAM_COUNT = "count" 110 | SERVICES_PARAM_VALUE = "value" 111 | SERVICES_PARAM_VALUES = "values" 112 | 113 | SERVICE_READ_HOLDING_REGISTERS = "read_holding_registers" 114 | SERVICE_READ_INPUT_REGISTERS = "read_input_registers" 115 | SERVICE_WRITE_SINGLE_REGISTER = "write_single_register" 116 | SERVICE_WRITE_MULTIPLE_REGISTERS = "write_multiple_registers" 117 | 118 | SERVICES_PARAM_REGISTER = "register" 119 | SERVICES_PARAM_QUANTITY = "quantity" 120 | DEPRECATION_SERVICE_WRITE_SINGLE_REGISTER = "write_holding_register" 121 | DEPRECATION_SERVICE_WRITE_MULTIPLE_REGISTERS = "write_multiple_holding_registers" 122 | 123 | DATETIME_FORMAT = "%y/%m/%d %H:%M:%S" 124 | TIME_FORMAT = "%H:%M" -------------------------------------------------------------------------------- /custom_components/solarman/coordinator.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from typing import Any 6 | from itertools import count 7 | from datetime import timedelta 8 | 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 11 | 12 | from .const import * 13 | from .common import * 14 | from .device import Device 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | class Coordinator(DataUpdateCoordinator[dict[str, Any]]): 19 | def __init__(self, hass: HomeAssistant, device: Device): 20 | self._counter: count | None = None 21 | self.device: Device = device 22 | super().__init__(hass, _LOGGER, name = device.config.name, update_interval = TIMINGS_UPDATE_INTERVAL, always_update = False) 23 | 24 | @DataUpdateCoordinator.update_interval.setter 25 | def update_interval(self, value: timedelta | None) -> None: 26 | DataUpdateCoordinator.update_interval.fset(self, value) 27 | self._counter = count(0, int(self._update_interval_seconds)) 28 | 29 | async def async_shutdown(self) -> None: 30 | await super().async_shutdown() 31 | try: 32 | await self.device.shutdown() 33 | except Exception as e: 34 | _LOGGER.exception(f"Unexpected error shutting down {self.name}") 35 | 36 | async def _async_setup(self) -> None: 37 | await super()._async_setup() 38 | try: 39 | await self.device.setup() 40 | except TimeoutError: 41 | raise 42 | except Exception as e: 43 | raise UpdateFailed(strepr(e)) from e 44 | 45 | async def async_config_entry_first_refresh(self) -> None: 46 | await super().async_config_entry_first_refresh() 47 | device_info = build_device_info(self.device.config.config_entry.entry_id, str(self.device.modbus.serial), self.device.endpoint.mac, self.device.endpoint.host, self.device.profile.info, self.device.config.name) 48 | self.device.device_info[self.device.config.config_entry.entry_id] = device_info 49 | _LOGGER.debug(device_info) 50 | 51 | async def _async_update_data(self) -> dict[str, Any]: 52 | try: 53 | return await self.device.get(next(self._counter)) 54 | except Exception as e: 55 | self._counter = count(0, int(self._update_interval_seconds)) 56 | if isinstance(e, TimeoutError): 57 | raise 58 | raise UpdateFailed(strepr(e)) from e 59 | -------------------------------------------------------------------------------- /custom_components/solarman/datetime.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from zoneinfo import ZoneInfo 6 | from datetime import datetime, timezone 7 | 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.components.datetime import DateTimeEntity, DateTimeEntityDescription 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | 12 | from .const import * 13 | from .common import * 14 | from .services import * 15 | from .entity import SolarmanConfigEntry, create_entity, SolarmanWritableEntity 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | _PLATFORM = get_current_file_name(__name__) 20 | 21 | async def async_setup_entry(_: HomeAssistant, config_entry: SolarmanConfigEntry, async_add_entities: AddEntitiesCallback) -> bool: 22 | _LOGGER.debug(f"async_setup_entry: {config_entry.options}") 23 | 24 | async_add_entities(create_entity(lambda x: SolarmanDateTimeEntity(config_entry.runtime_data, x), d) for d in postprocess_descriptions(config_entry.runtime_data, _PLATFORM)) 25 | 26 | return True 27 | 28 | async def async_unload_entry(_: HomeAssistant, config_entry: SolarmanConfigEntry) -> bool: 29 | _LOGGER.debug(f"async_unload_entry: {config_entry.options}") 30 | 31 | return True 32 | 33 | class SolarmanDateTimeEntity(SolarmanWritableEntity, DateTimeEntity): 34 | def __init__(self, coordinator, sensor): 35 | SolarmanWritableEntity.__init__(self, coordinator, sensor) 36 | 37 | self._time_zone = ZoneInfo(self.coordinator.hass.config.time_zone) 38 | self._multiple_registers = len(self.registers) > 3 and self.registers[3] == self.registers[0] + 3 39 | 40 | def _to_native_value(self, value: datetime) -> list: 41 | # Bug in HA: value set from the device detail page does not have correct tzinfo (set using AUTOMATIONS/ACTIONS works as expected) 42 | if value.tzinfo == timezone.utc: 43 | value = value.astimezone(ZoneInfo(self.coordinator.hass.config.time_zone)) 44 | if self._multiple_registers: 45 | return [value.year - 2000, value.month, value.day, value.hour, value.minute, value.second] 46 | return [(value.year - 2000 << 8) + value.month, (value.day << 8) + value.hour, (value.minute << 8) + value.second] 47 | 48 | @property 49 | def native_value(self) -> datetime | None: 50 | """Return the value reported by the datetime.""" 51 | try: 52 | if self._attr_native_value: 53 | return datetime.strptime(self._attr_native_value, DATETIME_FORMAT).replace(tzinfo = ZoneInfo(self.coordinator.hass.config.time_zone)) 54 | except Exception as e: 55 | _LOGGER.debug(f"SolarmanDateTimeEntity.native_value of {self._attr_name}: {e!r}") 56 | return None 57 | 58 | async def async_set_value(self, value: datetime) -> None: 59 | """Change the date/time.""" 60 | await self.write(self._to_native_value(value), value.strftime(DATETIME_FORMAT)) 61 | -------------------------------------------------------------------------------- /custom_components/solarman/device.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from datetime import datetime, timedelta 4 | 5 | from .const import * 6 | from .common import * 7 | from .provider import * 8 | from .pysolarman.pysolarman import Solarman 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | class DeviceState(): 13 | def __init__(self): 14 | self.updated: datetime = datetime.now() 15 | self.updated_interval: timedelta = 0 16 | self.value: int = -1 17 | 18 | @property 19 | def print(self): 20 | return "Connected" if self.value > 0 else "Disconnected" 21 | 22 | def update(self, init: bool = False, exception: Exception | None = None) -> bool: 23 | now = datetime.now() 24 | if not init: 25 | if not exception: 26 | self.updated, self.updated_interval = now, now - self.updated 27 | self.value = 1 28 | else: 29 | self.value = 0 if self.value == 1 else -1 30 | else: 31 | self.updated = now 32 | return self.value == -1 33 | 34 | class Device(): 35 | def __init__(self, config: ConfigurationProvider): 36 | self.config = config 37 | 38 | #self._write_lock = True 39 | 40 | self.state = DeviceState() 41 | self.endpoint: EndPointProvider | None = None 42 | self.profile: ProfileProvider | None = None 43 | self.modbus: Solarman | None = None 44 | self.device_info = {} 45 | 46 | async def setup(self) -> None: 47 | try: 48 | self.endpoint = await EndPointProvider(self.config).discover() 49 | self.profile = ProfileProvider(self.config, self.endpoint) 50 | self.modbus = Solarman(*self.endpoint.connection) 51 | await self.profile.resolve(self.get) 52 | except Exception as e: 53 | raise type(e)(f"{"Timeout" if (x := isinstance(e, TimeoutError)) else "Error"} setuping {self.config.name}{"" if x else f": {e!r}"}") from e 54 | else: 55 | self.state.update(True) 56 | 57 | #def check(self, lock) -> None: 58 | # if lock and self._write_lock: 59 | # raise UserWarning("Entity is locked!") 60 | 61 | async def shutdown(self) -> None: 62 | self.state.value = -1 63 | await self.modbus.close() 64 | 65 | async def execute(self, code, address, **kwargs): 66 | _LOGGER.debug(f"[{self.endpoint.host}] Request {code:02} ❘ 0x{code:02X} ~ {address:04} ❘ 0x{address:04X}: {kwargs}") 67 | 68 | try: 69 | return await self.modbus.execute(code, address, **kwargs) 70 | except TimeoutError: 71 | await self.endpoint.discover() 72 | raise 73 | 74 | @retry(ignore = TimeoutError) 75 | async def execute_bulk(self, requests, scheduled): 76 | responses = {} 77 | 78 | for code, address, _, count in ((get_request_code(request), request[REQUEST_START], request[REQUEST_END], request[REQUEST_COUNT]) for request in scheduled): 79 | responses[(code, address)] = await self.execute(code, address, count = count) 80 | 81 | return self.profile.parser.process(responses) if requests is None else responses 82 | 83 | async def get(self, runtime = 0, requests = None): 84 | scheduled, scount, result = *ensure_list_safe_len(self.profile.parser.schedule_requests(runtime) if requests is None else requests), {} 85 | 86 | if scount == 0: 87 | return result 88 | 89 | _LOGGER.debug(f"[{self.endpoint.host}] Scheduling {scount} query request{'s' if scount != 1 else ''}: {scheduled} #{runtime}") 90 | 91 | try: 92 | result = await self.execute_bulk(requests, scheduled) 93 | except Exception as e: 94 | if self.state.update(exception = e): 95 | await self.modbus.close() 96 | if self.profile.parser: 97 | self.profile.parser.reset() 98 | raise 99 | _LOGGER.debug(f"[{self.endpoint.host}] {"Timeout" if (x := isinstance(e, TimeoutError)) else "Error"} fetching {self.config.name} data{"" if x else f": {e!r}"}") 100 | 101 | if (rcount := len(result) if result else 0): 102 | _LOGGER.debug(f"[{self.endpoint.host}] Returning {rcount} new value{'s' if rcount > 1 else ''}") 103 | self.state.update() 104 | 105 | return result 106 | -------------------------------------------------------------------------------- /custom_components/solarman/discovery.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import socket 4 | import logging 5 | import asyncio 6 | 7 | from ipaddress import IPv4Network 8 | from datetime import datetime, timedelta 9 | 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.components import network 12 | 13 | from .const import * 14 | from .common import * 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | class DiscoveryProtocol: 19 | def __init__(self, addresses: list[str] | str): 20 | self.addresses = addresses 21 | self.responses: asyncio.Queue = asyncio.Queue() 22 | self.transport: asyncio.DatagramTransport | None = None 23 | 24 | def connection_made(self, transport: asyncio.DatagramTransport): 25 | self.transport = transport 26 | _LOGGER.debug(f"DiscoveryProtocol: Send to {self.addresses}") 27 | for address in ensure_list(self.addresses): 28 | for message in DISCOVERY_MESSAGE: 29 | self.transport.sendto(message, (address, DISCOVERY_PORT)) 30 | 31 | def datagram_received(self, data: bytes, addr: tuple[str, int]): 32 | if len(d := data.decode().split(',')) == 3 and (s := int(d[2])): 33 | self.responses.put_nowait((s, {"ip": d[0], "mac": d[1]})) 34 | _LOGGER.debug(f"DiscoveryProtocol: [{d[0]}, {d[1]}, {s}] from {addr}") 35 | 36 | def error_received(self, e: OSError): 37 | _LOGGER.debug(f"DiscoveryProtocol: {e!r}") 38 | 39 | def connection_lost(self, _): 40 | pass 41 | 42 | class Discovery: 43 | semaphore = asyncio.Semaphore(1) 44 | networks: list[IPv4Network] | None = None 45 | devices: dict | None = None 46 | d_when: datetime | None = None 47 | 48 | def __init__(self, hass: HomeAssistant): 49 | self._hass = hass 50 | self._devices = {} 51 | 52 | async def _discover(self, addresses: list[str] | str = IP_BROADCAST, wait: bool = False): 53 | loop = asyncio.get_running_loop() 54 | 55 | try: 56 | transport, protocol = await loop.create_datagram_endpoint(lambda: DiscoveryProtocol(addresses), family = socket.AF_INET, allow_broadcast = True) 57 | r: tuple = None 58 | while r is None or wait: 59 | r = await asyncio.wait_for(protocol.responses.get(), DISCOVERY_TIMEOUT) 60 | yield r 61 | except TimeoutError: 62 | pass 63 | except Exception as e: 64 | _LOGGER.debug(f"_discover: {e!r}") 65 | finally: 66 | transport.close() 67 | 68 | async def _discover_all(self): 69 | if Discovery.networks is None: 70 | _LOGGER.debug(f"_discover_all: network.async_get_adapters") 71 | Discovery.networks = [x for x in [IPv4Network(ipv4["address"] + '/' + str(ipv4["network_prefix"]), False) for adapter in await network.async_get_adapters(self._hass) if len(adapter["ipv4"]) > 0 for ipv4 in adapter["ipv4"]] if not x.is_loopback] 72 | 73 | async for item in self._discover([str(net.broadcast_address) for net in Discovery.networks], True): 74 | yield item 75 | 76 | async def discover(self, address: str | None = None): 77 | self._devices = {} 78 | 79 | if address: 80 | if (devices := {item[0]: item[1] async for item in self._discover(address)}) and any([v["ip"] == address for v in devices.values()]): 81 | self._devices = devices 82 | return self._devices 83 | 84 | if not self._devices: 85 | async with Discovery.semaphore: 86 | if Discovery.devices is not None and (datetime.now() - Discovery.d_when) < timedelta(seconds = 10): 87 | return Discovery.devices 88 | Discovery.devices = self._devices = {item[0]: item[1] async for item in self._discover_all()} 89 | Discovery.d_when = datetime.now() 90 | 91 | return self._devices 92 | -------------------------------------------------------------------------------- /custom_components/solarman/entity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from typing import Any 6 | from decimal import Decimal 7 | from datetime import date, datetime, time 8 | 9 | from homeassistant.util import slugify 10 | from homeassistant.core import split_entity_id, callback 11 | from homeassistant.const import EntityCategory, STATE_UNKNOWN, CONF_FRIENDLY_NAME 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.helpers.entity import EntityDescription 14 | from homeassistant.helpers.entity_registry import RegistryEntry 15 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 16 | from homeassistant.helpers.typing import UNDEFINED, StateType, UndefinedType 17 | 18 | from .const import * 19 | from .common import * 20 | from .services import * 21 | from .coordinator import Coordinator 22 | from .pysolarman.umodbus.functions import FUNCTION_CODE 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | type SolarmanConfigEntry = ConfigEntry[Coordinator] 27 | 28 | @callback 29 | def migrate_unique_ids(config_entry: SolarmanConfigEntry, entity_entry: RegistryEntry) -> dict[str, Any] | None: 30 | 31 | entity_name = entity_entry.original_name if entity_entry.has_entity_name or not entity_entry.original_name else entity_entry.original_name.replace(config_entry.runtime_data.device.config.name, '').strip() 32 | 33 | if entity_entry.unique_id != (unique_id := slugify('_'.join(filter(None, (config_entry.entry_id, entity_name, split_entity_id(entity_entry.entity_id)[0]))))): 34 | _LOGGER.debug(f"Migrating unique_id for {entity_entry.entity_id} entity from '{entity_entry.unique_id}' to '{unique_id}]'") 35 | return { "new_unique_id": entity_entry.unique_id.replace(entity_entry.unique_id, unique_id) } 36 | 37 | return None 38 | 39 | def create_entity(creator, description): 40 | try: 41 | entity = creator(description) 42 | 43 | entity.update() 44 | 45 | return entity 46 | except Exception as e: 47 | _LOGGER.error(f"Configuring {description} failed. [{e!r}]") 48 | raise 49 | 50 | class SolarmanCoordinatorEntity(CoordinatorEntity[Coordinator]): 51 | def __init__(self, coordinator: Coordinator): 52 | super().__init__(coordinator) 53 | self._attr_device_info = self.coordinator.device.device_info.get(self.coordinator.device.config.config_entry.entry_id) 54 | self._attr_state: StateType = STATE_UNKNOWN 55 | self._attr_native_value: StateType | str | date | datetime | time | float | Decimal | None = None 56 | self._attr_extra_state_attributes: dict[str, Any] = {} 57 | self._attr_value: None = None 58 | 59 | @property 60 | def device_name(self) -> str: 61 | return (device_entry.name_by_user or device_entry.name) if (device_entry := self.device_entry) else self.coordinator.device.config.name 62 | 63 | @property 64 | def available(self) -> bool: 65 | return self.coordinator.last_update_success and self.coordinator.device.state.value > -1 66 | 67 | @callback 68 | def _handle_coordinator_update(self) -> None: 69 | self.update() 70 | self.async_write_ha_state() 71 | 72 | def set_state(self, state, value = None) -> bool: 73 | self._attr_native_value = self._attr_state = state 74 | if value is not None: 75 | self._attr_extra_state_attributes["value"] = self._attr_value = value 76 | return True 77 | 78 | def update(self): 79 | if (data := self.coordinator.data.get(self._attr_key)) is not None and self.set_state(*data) and self.attributes: 80 | if "inverse_sensor" in self.attributes and self._attr_native_value: 81 | self._attr_extra_state_attributes["−x"] = -self._attr_native_value 82 | for attr in filter(lambda a: a in self.coordinator.data, self.attributes): 83 | self._attr_extra_state_attributes[self.attributes[attr].replace(f"{self._attr_name} ", "")] = get_tuple(self.coordinator.data.get(attr)) 84 | 85 | class SolarmanEntity(SolarmanCoordinatorEntity): 86 | def __init__(self, coordinator, sensor): 87 | super().__init__(coordinator) 88 | 89 | self._attr_key = sensor["key"] 90 | self._attr_name = sensor["name"] 91 | self._attr_has_entity_name = True 92 | self._attr_device_class = sensor.get("class") or sensor.get("device_class") 93 | self._attr_translation_key = sensor.get("translation_key") or slugify(self._attr_name) 94 | self._attr_unique_id = slugify('_'.join(filter(None, (self.coordinator.device.config.config_entry.entry_id, self._attr_key)))) 95 | self._attr_entity_category = sensor.get("category") or sensor.get("entity_category") 96 | self._attr_entity_registry_enabled_default = not "disabled" in sensor 97 | self._attr_entity_registry_visible_default = not "hidden" in sensor 98 | self._attr_friendly_name = sensor.get(CONF_FRIENDLY_NAME) 99 | self._attr_icon = sensor.get("icon") 100 | 101 | if (unit_of_measurement := sensor.get("uom") or sensor.get("unit_of_measurement")): 102 | self._attr_native_unit_of_measurement = unit_of_measurement 103 | if (options := sensor.get("options")): 104 | self._attr_options = options 105 | self._attr_extra_state_attributes = self._attr_extra_state_attributes | { "options": options } 106 | elif "lookup" in sensor and "rule" in sensor and 0 < sensor["rule"] < 5 and (options := [s["value"] for s in sensor["lookup"]]): 107 | self._attr_device_class = "enum" 108 | self._attr_options = options 109 | self._attr_extra_state_attributes = self._attr_extra_state_attributes | { "options": options } 110 | if alt := sensor.get("alt"): 111 | self._attr_extra_state_attributes = self._attr_extra_state_attributes | { "Alt Name": alt } 112 | if description := sensor.get("description"): 113 | self._attr_extra_state_attributes = self._attr_extra_state_attributes | { "description": description } 114 | 115 | self.attributes = {slugify('_'.join(filter(None, (x, "sensor")))): x for x in attrs} if (attrs := sensor.get("attributes")) is not None else None 116 | self.registers = sensor.get("registers") 117 | 118 | def _friendly_name_internal(self) -> str | None: 119 | name = self.name if self.name is not UNDEFINED else None 120 | if self.platform and (name_translation_key := self._name_translation_key) and (n := self.platform.platform_translations.get(name_translation_key)): 121 | name = self._substitute_name_placeholders(n) 122 | elif self._attr_friendly_name: 123 | name = self._attr_friendly_name 124 | if not self.has_entity_name or not (device_name := self.device_name): 125 | return name 126 | if name is None and self.use_device_name: 127 | return device_name 128 | return f"{device_name} {name}" 129 | 130 | class SolarmanWritableEntity(SolarmanEntity): 131 | def __init__(self, coordinator, sensor): 132 | super().__init__(coordinator, sensor) 133 | 134 | #self._write_lock = "locked" in sensor 135 | 136 | if not "control" in sensor: 137 | self._attr_entity_category = EntityCategory.CONFIG 138 | 139 | self.code = get_code(sensor, "write", FUNCTION_CODE.WRITE_MULTIPLE_REGISTERS) 140 | self.register = min(self.registers) if len(self.registers) > 0 else None 141 | self.maxint = 0xFFFFFFFF if len(self.registers) > 2 else 0xFFFF 142 | 143 | @property 144 | def _get_attr_native_value(self): 145 | if self._attr_native_value is None: 146 | raise RuntimeError( 147 | f"{self.name}: Cannot write value when _attr_native_value is None. " 148 | "This likely means the entity has not received data from the device" 149 | ) 150 | return self._attr_native_value 151 | 152 | async def write(self, value, state = None) -> None: 153 | #self.coordinator.device.check(self._write_lock) 154 | data = value 155 | if isinstance(data, int): 156 | if data < 0: 157 | data = data + self.maxint 158 | if data > 0xFFFF: 159 | data = list(split_p16b(data)) 160 | if len(self.registers) > 1 or self.code > FUNCTION_CODE.WRITE_SINGLE_REGISTER: 161 | data = ensure_list(data) 162 | if isinstance(data, list): 163 | while len(self.registers) > len(data): 164 | data.insert(0, 0) 165 | if await self.coordinator.device.execute(self.code, self.register, data = data) > 0 and state is not None: 166 | self.set_state(state, value) 167 | self.async_write_ha_state() 168 | #await self.entity_description.update_fn(self.coordinator., int(value)) 169 | #await self.coordinator.async_request_refresh() 170 | -------------------------------------------------------------------------------- /custom_components/solarman/inverter_definitions/afore_2mppt.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Afore BNT-TL T6 | 2 MPPT 3 | # 4 | # Reference: https://github.com/user-attachments/files/17139137/211208.-.Afore.T6.communication.protocol.V1.0-211109.xlsx 5 | # 6 | # For older models of BNTxxxKTL profile w/ T4 protocol is needed 7 | # To use modbus function in Afore BNTxxxKTL inverters, You first need to change protocol from RS485 to MODBUS in inverter menu 8 | # 9 | # Tested with Afore ATON BNT004KTL inverter w/ serial number starting from T6 10 | # 11 | 12 | default: 13 | update_interval: 5 14 | code: 0x04 15 | digits: 6 16 | 17 | parameters: 18 | - group: PV 19 | items: 20 | - name: PV Power 21 | alt: DC Power 22 | mppt: 1 23 | description: Combined power of all inputs 24 | class: "power" 25 | state_class: "measurement" 26 | uom: "W" 27 | scale: 1 28 | rule: 1 29 | registers: [0x022A, 0x0229] 30 | icon: "mdi:home-lightning-bolt" 31 | 32 | - name: PV1 Power 33 | alt: DC1 Power 34 | mppt: 1 35 | class: "power" 36 | state_class: "measurement" 37 | uom: "W" 38 | rule: 1 39 | digits: 3 40 | scale: 1 41 | registers: [0x022D] 42 | icon: "mdi:solar-power-variant" 43 | 44 | - name: "PV1 Voltage" 45 | alt: DC1 Voltage 46 | mppt: 1 47 | class: "voltage" 48 | state_class: "measurement" 49 | uom: "V" 50 | scale: 0.1 51 | rule: 1 52 | registers: [0x022B] 53 | icon: "mdi:solar-power" 54 | 55 | - name: "PV1 Current" 56 | alt: DC1 Current 57 | mppt: 1 58 | class: "current" 59 | state_class: "measurement" 60 | uom: "A" 61 | scale: 0.01 62 | rule: 1 63 | registers: [0x022C] 64 | icon: "mdi:solar-power" 65 | 66 | - name: PV2 Power 67 | alt: DC2 Power 68 | mppt: 2 69 | class: "power" 70 | state_class: "measurement" 71 | uom: "W" 72 | rule: 1 73 | digits: 3 74 | scale: 1 75 | registers: [0x022C] 76 | icon: "mdi:solar-power-variant" 77 | 78 | - name: "PV2 Voltage" 79 | alt: DC2 Voltage 80 | mppt: 2 81 | class: "voltage" 82 | state_class: "measurement" 83 | uom: "V" 84 | scale: 0.1 85 | rule: 1 86 | registers: [0x022E] 87 | icon: "mdi:solar-power" 88 | 89 | - name: "PV2 Current" 90 | alt: DC2 Current 91 | mppt: 2 92 | class: "current" 93 | state_class: "measurement" 94 | uom: "A" 95 | scale: 0.01 96 | rule: 1 97 | registers: [0x022F] 98 | icon: "mdi:solar-power" 99 | 100 | - name: "Today Production" 101 | friendly_name: Today's Production 102 | class: "energy" 103 | state_class: "total_increasing" 104 | uom: "Wh" 105 | scale: 0.1 106 | rule: 1 107 | registers: [0x03E8] 108 | icon: "mdi:solar-power" 109 | 110 | - name: "Total Production" 111 | class: "energy" 112 | state_class: "total_increasing" 113 | uom: "Wh" 114 | scale: 0.1 115 | rule: 1 116 | registers: [0x03F7, 0x03F6] 117 | icon: "mdi:solar-power" 118 | validation: 119 | min: 0.1 120 | dev: 100 121 | invalidate_all: 2 122 | 123 | - group: Output 124 | items: 125 | - name: "L1 Voltage" 126 | l: 1 127 | class: "voltage" 128 | state_class: "measurement" 129 | uom: "V" 130 | scale: 0.1 131 | rule: 1 132 | registers: [0x01FB] 133 | 134 | - name: "L2 Voltage" 135 | l: 2 136 | class: "voltage" 137 | state_class: "measurement" 138 | uom: "V" 139 | scale: 0.1 140 | rule: 1 141 | registers: [0x01FC] 142 | 143 | - name: "L3 Voltage" 144 | l: 3 145 | class: "voltage" 146 | state_class: "measurement" 147 | uom: "V" 148 | scale: 0.1 149 | rule: 1 150 | registers: [0x01FD] 151 | 152 | - name: "L1 Current" 153 | l: 1 154 | class: "current" 155 | state_class: "measurement" 156 | uom: "A" 157 | scale: 0.01 158 | rule: 2 159 | registers: [0x01FE] 160 | 161 | - name: "L2 Current" 162 | l: 2 163 | class: "current" 164 | state_class: "measurement" 165 | uom: "A" 166 | scale: 0.01 167 | rule: 2 168 | registers: [0x01FF] 169 | 170 | - name: "L3 Current" 171 | l: 3 172 | class: "current" 173 | state_class: "measurement" 174 | uom: "A" 175 | scale: 0.01 176 | rule: 2 177 | registers: [0x0200] 178 | 179 | - name: "L1 Frequency" 180 | l: 1 181 | class: "frequency" 182 | state_class: "measurement" 183 | uom: "Hz" 184 | scale: 0.01 185 | rule: 1 186 | registers: [0x0201] 187 | 188 | - name: "L2 Frequency" 189 | l: 2 190 | class: "frequency" 191 | state_class: "measurement" 192 | uom: "Hz" 193 | scale: 0.01 194 | rule: 1 195 | registers: [0x0202] 196 | 197 | - name: "L3 Frequency" 198 | l: 3 199 | class: "frequency" 200 | state_class: "measurement" 201 | uom: "Hz" 202 | scale: 0.01 203 | rule: 1 204 | registers: [0x0203] 205 | 206 | - name: "Temperature" 207 | class: "temperature" 208 | uom: "°C" 209 | scale: 0.1 210 | rule: 2 211 | registers: [0x09D2] 212 | icon: "mdi:thermometer" 213 | 214 | - name: "DC Temperature" 215 | class: "temperature" 216 | state_class: "measurement" 217 | uom: "°C" 218 | scale: 0.1 219 | rule: 2 220 | registers: [0x09D3] 221 | icon: "mdi:thermometer" 222 | 223 | - name: Power 224 | class: "power" 225 | state_class: "measurement" 226 | uom: "W" 227 | scale: 1 228 | rule: 2 229 | registers: [0x020B, 0x020A] 230 | icon: "mdi:home-lightning-bolt" 231 | 232 | - name: "Power losses" 233 | description: Includes consumption of the inverter device itself as well AC/DC conversion losses 234 | class: "power" 235 | state_class: "measurement" 236 | uom: "W" 237 | rule: 1 238 | digits: 0 239 | uint: enforce 240 | sensors: 241 | - registers: [0x022A, 0x0229] 242 | - operator: subtract 243 | signed: 244 | registers: [0x020B, 0x020A] 245 | 246 | - name: "Device State" 247 | rule: 1 248 | registers: [0x09C4] 249 | icon: "mdi:state-machine" 250 | lookup: 251 | - key: 0 252 | value: "Init" 253 | - key: 1 254 | value: "Standby" 255 | - key: 2 256 | value: "Startup" 257 | - key: 3 258 | value: "Grid" 259 | - key: 4 260 | value: "Grid disconnected" 261 | - key: 5 262 | value: "Generator" 263 | - key: 6 264 | value: "Off grid" 265 | - key: 7 266 | value: "On grid" 267 | - key: 8 268 | value: "Shutdown" 269 | - key: 9 270 | value: "Off" 271 | - key: 10 272 | value: "Error" 273 | - key: 11 274 | value: "Update" 275 | - key: 12 276 | value: "Aging" 277 | - key: 13 278 | value: "Open loop" 279 | - key: 14 280 | value: "Sampling calibration" 281 | -------------------------------------------------------------------------------- /custom_components/solarman/inverter_definitions/astro-energy_2mppt.yaml: -------------------------------------------------------------------------------- 1 | info: 2 | manufacturer: Astro-Energy 3 | model: TM-L series 4 | 5 | default: 6 | update_interval: 5 7 | max_size: 60 8 | 9 | parameters: 10 | - group: PV 11 | items: 12 | - name: PV Power 13 | alt: DC Power 14 | mppt: 1 15 | description: Combined power of all inputs 16 | class: "power" 17 | state_class: "measurement" 18 | uom: "W" 19 | rule: 1 20 | digits: 3 21 | scale: 0.1 22 | sensors: 23 | - registers: [0x0000] 24 | multiply: 25 | registers: [0x0005] 26 | scale: 0.01 27 | - registers: [0x0001] 28 | multiply: 29 | registers: [0x0006] 30 | scale: 0.01 31 | validation: 32 | min: 0 33 | max: 4000 34 | invalidate_all: 35 | icon: "mdi:solar-power-variant" 36 | 37 | - name: PV1 Power 38 | alt: DC1 Power 39 | mppt: 1 40 | class: "power" 41 | state_class: "measurement" 42 | uom: "W" 43 | rule: 1 44 | digits: 3 45 | scale: 0.1 46 | sensors: 47 | - registers: [0x0000] 48 | multiply: 49 | registers: [0x0005] 50 | scale: 0.01 51 | icon: "mdi:solar-power-variant" 52 | 53 | - name: "PV1 Voltage" 54 | alt: DC1 Voltage 55 | mppt: 1 56 | class: "voltage" 57 | state_class: "measurement" 58 | uom: "V" 59 | scale: 0.1 60 | rule: 1 61 | registers: [0x0000] 62 | icon: "mdi:solar-power-variant" 63 | 64 | - name: "PV1 Current" 65 | alt: DC1 Current 66 | mppt: 1 67 | class: "current" 68 | state_class: "measurement" 69 | uom: "A" 70 | scale: 0.01 71 | rule: 1 72 | registers: [0x0005] 73 | icon: "mdi:solar-power-variant" 74 | 75 | - name: PV2 Power 76 | alt: DC2 Power 77 | mppt: 2 78 | class: "power" 79 | state_class: "measurement" 80 | uom: "W" 81 | rule: 1 82 | digits: 3 83 | scale: 0.1 84 | sensors: 85 | - registers: [0x0001] 86 | multiply: 87 | registers: [0x0006] 88 | scale: 0.01 89 | icon: "mdi:solar-power-variant" 90 | 91 | - name: "PV2 Voltage" 92 | alt: DC2 Voltage 93 | mppt: 2 94 | class: "voltage" 95 | state_class: "measurement" 96 | uom: "V" 97 | scale: 0.1 98 | rule: 1 99 | registers: [0x0001] 100 | icon: "mdi:solar-power-variant" 101 | 102 | - name: "PV2 Current" 103 | alt: DC2 Current 104 | mppt: 2 105 | class: "current" 106 | state_class: "measurement" 107 | uom: "A" 108 | scale: 0.01 109 | rule: 1 110 | registers: [0x0006] 111 | icon: "mdi:solar-power-variant" 112 | 113 | - name: "Energy" 114 | persistent: 115 | class: "energy" 116 | state_class: "total_increasing" 117 | uom: "kWh" 118 | scale: 0.01 119 | rule: 1 120 | registers: [0x0014] 121 | icon: "mdi:solar-power" 122 | validation: 123 | min: 0.1 124 | dev: 100 125 | invalidate_all: 2 126 | 127 | - group: Grid 128 | items: 129 | - name: "Current" 130 | class: "current" 131 | state_class: "measurement" 132 | uom: "A" 133 | scale: 0.01 134 | rule: 2 135 | registers: [0x0009] 136 | 137 | - name: "Voltage" 138 | class: "voltage" 139 | state_class: "measurement" 140 | uom: "V" 141 | scale: 0.1 142 | rule: 1 143 | registers: [0x000B] 144 | 145 | - name: "Frequency" 146 | class: "frequency" 147 | state_class: "measurement" 148 | uom: "Hz" 149 | scale: 0.01 150 | rule: 1 151 | registers: [0x000D] 152 | 153 | - name: "Power" 154 | class: "power" 155 | state_class: "measurement" 156 | uom: "W" 157 | scale: 0.1 158 | rule: 2 159 | registers: [0x000F] 160 | 161 | - name: "Temperature" 162 | class: "temperature" 163 | uom: "°C" 164 | state_class: "measurement" 165 | scale: 0.01 166 | rule: 1 167 | registers: [0x0016] 168 | 169 | - group: Info 170 | items: 171 | - name: "MPPTs" 172 | rule: 1 173 | registers: [0x0017] 174 | -------------------------------------------------------------------------------- /custom_components/solarman/inverter_definitions/solis_1p-5g.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Solis 1P8K-5G | 8kW | 2 MPPT | Single Phase Inverter 3 | # 4 | # Reference: ??? 5 | # - Modbus information derived by test and comparing to Solis Cloud 6 | # 7 | # Tested by Gedger in July 2024 8 | # 9 | 10 | default: 11 | update_interval: 30 12 | code: 0x04 13 | digits: 6 14 | 15 | parameters: 16 | - group: InverterStatus 17 | items: 18 | - name: "Working Mode" 19 | update_interval: 300 20 | rule: 1 21 | registers: [3040] 22 | icon: "mdi:list-status" 23 | lookup: 24 | - key: 0x0 25 | value: "No response mode" 26 | - key: 0x1 27 | value: "Volt–watt default" 28 | - key: 0x2 29 | value: "Volt–var" 30 | - key: 0x3 31 | value: "Fixed power factor" 32 | - key: 0x4 33 | value: "Fix reactive power" 34 | - key: 0x5 35 | value: "Power-PF" 36 | - key: 0x6 37 | value: "Rule21Volt–watt" 38 | 39 | - name: "Inverter Status" 40 | update_interval: 60 41 | rule: 1 42 | registers: [3043] 43 | icon: "mdi:list-status" 44 | lookup: 45 | - key: 0x0 46 | value: "Waiting" 47 | - key: 0x1 48 | value: "OpenRun" 49 | - key: 0x2 50 | value: "SoftRun" 51 | - key: 0x3 52 | value: "Generating" 53 | - key: 0x1004 54 | value: "Grid off" 55 | - key: 0x1010 56 | value: "Grid Over Voltage" 57 | - key: 0x1011 58 | value: "Grid Under Voltage" 59 | - key: 0x1015 60 | value: "No Grid" 61 | - key: 0x1032 62 | value: "Temperature Protection" 63 | - key: 0x2011 64 | value: "Fail Safe" 65 | 66 | - name: "Working Status" 67 | rule: 1 68 | registers: [3071] 69 | icon: "mdi:list-status" 70 | 71 | # Working Status Bit decode 72 | - name: "WStatus Normal" 73 | mask: 0x01 74 | divide: 1 75 | rule: 1 76 | registers: [3071] 77 | icon: "mdi:list-status" 78 | 79 | - name: "WStatus Initialising" 80 | mask: 0x02 81 | divide: 2 82 | rule: 1 83 | registers: [3071] 84 | icon: "mdi:list-status" 85 | 86 | - name: "WStatus Grid off" 87 | mask: 0x04 88 | divide: 4 89 | rule: 1 90 | registers: [3071] 91 | icon: "mdi:list-status" 92 | 93 | - name: "WStatus Standby" 94 | mask: 0x10 95 | divide: 16 96 | rule: 1 97 | registers: [3071] 98 | icon: "mdi:list-status" 99 | 100 | - name: "WStatus Derating" 101 | mask: 0x20 102 | divide: 32 103 | rule: 1 104 | registers: [3071] 105 | icon: "mdi:list-status" 106 | 107 | - name: "WStatus Limiting" 108 | mask: 0x40 109 | divide: 64 110 | rule: 1 111 | registers: [3071] 112 | icon: "mdi:list-status" 113 | 114 | - name: "Inverter Temperature" 115 | class: "temperature" 116 | state_class: "measurement" 117 | uom: "°C" 118 | scale: 0.1 119 | rule: 2 120 | registers: [3041] 121 | icon: "mdi:thermometer" 122 | 123 | - name: "Inverter Efficiency" 124 | state_class: measurement 125 | uom: "%" 126 | rule: 1 127 | digits: 1 128 | uint: enforce 129 | sensors: 130 | - signed: 131 | registers: [3005, 3004] 132 | scale: 100 133 | - operator: divide 134 | signed: 135 | registers: [3007, 3006] 136 | validation: 137 | min: 0 138 | max: 99 139 | 140 | - name: "Inverter ID" 141 | disabled: 142 | rule: 5 143 | registers: 144 | [ 145 | 33004, 146 | 33005, 147 | 33006, 148 | 33007, 149 | 33008, 150 | 33009, 151 | 33010, 152 | 33011, 153 | 33012, 154 | 33013, 155 | 33014, 156 | 33015, 157 | 33016, 158 | 33017, 159 | 33018, 160 | 33019, 161 | ] 162 | 163 | - name: "Product Model" 164 | disabled: 165 | rule: 6 166 | registers: [2999] 167 | 168 | - name: "DSP Software Version" 169 | disabled: 170 | rule: 6 171 | registers: [3000] 172 | 173 | - name: "LCD Software Version" 174 | disabled: 175 | rule: 6 176 | registers: [3001] 177 | 178 | - group: PV 179 | items: 180 | - name: "PV Power" 181 | class: "power" 182 | mppt: 1 183 | state_class: "measurement" 184 | uom: "kW" 185 | scale: 0.001 186 | rule: 3 187 | registers: [3007, 3006] 188 | icon: "mdi:solar-power" 189 | 190 | - name: "PV1 Voltage" 191 | class: "voltage" 192 | mppt: 1 193 | state_class: "measurement" 194 | uom: "V" 195 | scale: 0.1 196 | rule: 1 197 | registers: [3021] 198 | icon: "mdi:solar-power" 199 | 200 | - name: "PV1 Current" 201 | class: "current" 202 | mppt: 1 203 | state_class: "measurement" 204 | uom: "A" 205 | scale: 0.1 206 | rule: 1 207 | registers: [3022] 208 | icon: "mdi:current-dc" 209 | 210 | - name: "PV1 Power" 211 | class: "power" 212 | mppt: 1 213 | state_class: "measurement" 214 | uom: "kW" 215 | rule: 1 216 | digits: 3 217 | sensors: 218 | - registers: [3021] 219 | scale: 0.1 220 | - operator: multiply 221 | scale: 0.0001 222 | registers: [3022] 223 | 224 | - name: "PV2 Voltage" 225 | class: "voltage" 226 | mppt: 2 227 | state_class: "measurement" 228 | uom: "V" 229 | scale: 0.1 230 | rule: 1 231 | registers: [3023] 232 | icon: "mdi:solar-power" 233 | 234 | - name: "PV2 Current" 235 | class: "current" 236 | mppt: 2 237 | state_class: "measurement" 238 | uom: "A" 239 | scale: 0.1 240 | rule: 1 241 | registers: [3024] 242 | icon: "mdi:current-dc" 243 | 244 | - name: "PV2 Power" 245 | class: "power" 246 | mppt: 2 247 | state_class: "measurement" 248 | uom: "kW" 249 | rule: 1 250 | digits: 3 251 | sensors: 252 | - registers: [3023] 253 | scale: 0.1 254 | - operator: multiply 255 | scale: 0.0001 256 | registers: [3024] 257 | 258 | - group: Load 259 | items: 260 | - name: "Load Power" 261 | class: "power" 262 | state_class: "measurement" 263 | uom: "kW" 264 | scale: 0.001 265 | rule: 3 266 | registers: [3005, 3004] 267 | icon: "mdi:solar-power" 268 | 269 | - name: "Load Voltage" 270 | class: "voltage" 271 | state_class: "measurement" 272 | uom: "V" 273 | scale: 0.1 274 | rule: 1 275 | registers: [3035] 276 | icon: "mdi:transmission-tower" 277 | 278 | - name: "Load Current" 279 | class: "current" 280 | state_class: "measurement" 281 | uom: "A" 282 | scale: 0.1 283 | rule: 1 284 | registers: [3038] 285 | icon: "mdi:current-ac" 286 | 287 | - name: "Load Frequency" 288 | class: "frequency" 289 | state_class: "measurement" 290 | uom: "Hz" 291 | scale: 0.01 292 | rule: 1 293 | registers: [3042] 294 | icon: "mdi:sine-wave" 295 | 296 | - name: "Inverter AC Export Power" 297 | class: "power" 298 | state_class: "measurement" 299 | uom: "kW" 300 | scale: 0.01 301 | rule: 1 302 | registers: [3113] 303 | icon: "mdi:transmission-tower" 304 | validation: 305 | min: 0 306 | max: 8 307 | 308 | - group: Production 309 | items: 310 | - name: "Total Production" 311 | class: "energy" 312 | state_class: "total_increasing" 313 | uom: "kWh" 314 | rule: 3 315 | registers: [3009, 3008] 316 | icon: "mdi:solar-power" 317 | validation: 318 | min: 0.1 319 | dev: 100 320 | invalidate_all: 2 321 | 322 | - name: "Today Production" 323 | friendly_name: Today's Production 324 | update_interval: 300 325 | class: "energy" 326 | state_class: "total_increasing" 327 | uom: "kWh" 328 | scale: 0.1 329 | rule: 1 330 | registers: [3014] 331 | icon: "mdi:solar-power" 332 | 333 | - name: "Monthly Production" 334 | class: "energy" 335 | state_class: "total_increasing" 336 | uom: "kWh" 337 | rule: 3 338 | registers: [3011, 3010] 339 | icon: "mdi:solar-power" 340 | 341 | - name: "Yearly Production" 342 | class: "energy" 343 | state_class: "total_increasing" 344 | uom: "kWh" 345 | rule: 3 346 | registers: [3017, 3016] 347 | icon: "mdi:solar-power" 348 | -------------------------------------------------------------------------------- /custom_components/solarman/inverter_definitions/solis_3p-4g.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Solis 3P5~10K-4G | 5~10kW | Three Phase Inverter 3 | # 4 | # Reference: https://ginlongsolis.freshdesk.com/support/solutions/articles/36000340158-modbus-communication-for-solis-inverters 5 | # 6 | # Tested by agirilovich in June 2023 7 | # 8 | 9 | default: 10 | update_interval: 5 11 | code: 0x04 12 | digits: 6 13 | 14 | parameters: 15 | - group: Inverter 16 | items: 17 | - name: "Working Mode" 18 | rule: 1 19 | registers: [3040] 20 | icon: "mdi:home-lightning-bolt" 21 | lookup: 22 | - key: 0 23 | value: "No response mode" 24 | - key: 1 25 | value: "Volt–watt default" 26 | - key: 2 27 | value: "Volt–var" 28 | - key: 3 29 | value: "Fixed power factor" 30 | - key: 4 31 | value: "Fix reactive power" 32 | - key: 5 33 | value: "Power-PF" 34 | - key: 6 35 | value: "Rule21Volt–watt" 36 | 37 | - name: "Inverter Temperature" 38 | class: "temperature" 39 | state_class: "measurement" 40 | uom: "°C" 41 | scale: 0.1 42 | rule: 1 43 | registers: [3041] 44 | icon: "mdi:thermometer" 45 | 46 | - name: "Product Model" 47 | rule: 1 48 | registers: [2999] 49 | 50 | - name: "DSP Software Version" 51 | rule: 1 52 | registers: [3000] 53 | 54 | - name: "LCD Software Version" 55 | rule: 1 56 | registers: [3001] 57 | 58 | - name: "Inverter Status" 59 | rule: 1 60 | registers: [3043] 61 | icon: "mdi:list-status" 62 | lookup: 63 | - key: 0 64 | value: "Waiting" 65 | - key: 1 66 | value: "OpenRun" 67 | - key: 2 68 | value: "SoftRun" 69 | - key: 3 70 | value: "Generating" 71 | - key: 1004 72 | value: "Grid off" 73 | - key: 2011 74 | value: "Fail Safe" 75 | 76 | - group: PV 77 | items: 78 | - name: "PV Power" 79 | class: "power" 80 | mppt: 1 81 | state_class: "measurement" 82 | uom: "kW" 83 | scale: 0.001 84 | rule: 3 85 | registers: [3007, 3006] 86 | icon: "mdi:solar-power-variant" 87 | 88 | - name: "PV1 Voltage" 89 | class: "voltage" 90 | mppt: 1 91 | state_class: "measurement" 92 | uom: "V" 93 | scale: 0.1 94 | rule: 1 95 | registers: [3021] 96 | icon: "mdi:solar-power-variant" 97 | 98 | - name: "PV1 Current" 99 | class: "current" 100 | mppt: 1 101 | state_class: "measurement" 102 | uom: "A" 103 | scale: 0.1 104 | rule: 1 105 | registers: [3022] 106 | icon: "mdi:solar-power-variant" 107 | 108 | - name: "PV1 Power" 109 | class: "power" 110 | mppt: 1 111 | state_class: "measurement" 112 | uom: "kW" 113 | rule: 1 114 | digits: 3 115 | sensors: 116 | - registers: [3021] 117 | scale: 0.1 118 | - operator: multiply 119 | scale: 0.0001 120 | registers: [3022] 121 | icon: "mdi:solar-power-variant" 122 | 123 | - name: "PV2 Voltage" 124 | class: "voltage" 125 | mppt: 2 126 | state_class: "measurement" 127 | uom: "V" 128 | scale: 0.1 129 | rule: 1 130 | registers: [3023] 131 | icon: "mdi:solar-power-variant" 132 | 133 | - name: "PV2 Current" 134 | class: "current" 135 | mppt: 2 136 | state_class: "measurement" 137 | uom: "A" 138 | scale: 0.1 139 | rule: 1 140 | registers: [3024] 141 | icon: "mdi:solar-power-variant" 142 | 143 | - name: "PV2 Power" 144 | class: "power" 145 | mppt: 2 146 | state_class: "measurement" 147 | uom: "kW" 148 | rule: 1 149 | digits: 3 150 | sensors: 151 | - registers: [3023] 152 | scale: 0.1 153 | - operator: multiply 154 | scale: 0.0001 155 | registers: [3024] 156 | icon: "mdi:solar-power-variant" 157 | 158 | - name: "PV3 Voltage" 159 | class: "voltage" 160 | mppt: 3 161 | state_class: "measurement" 162 | uom: "V" 163 | scale: 0.1 164 | rule: 1 165 | registers: [3025] 166 | icon: "mdi:solar-power-variant" 167 | 168 | - name: "PV3 Current" 169 | class: "current" 170 | mppt: 3 171 | state_class: "measurement" 172 | uom: "A" 173 | scale: 0.1 174 | rule: 1 175 | registers: [3026] 176 | icon: "mdi:solar-power-variant" 177 | 178 | - name: "PV3 Power" 179 | class: "power" 180 | mppt: 3 181 | state_class: "measurement" 182 | uom: "kW" 183 | rule: 1 184 | digits: 3 185 | sensors: 186 | - registers: [3025] 187 | scale: 0.1 188 | - operator: multiply 189 | scale: 0.0001 190 | registers: [3026] 191 | icon: "mdi:solar-power-variant" 192 | 193 | - group: Load 194 | items: 195 | - name: "Load Power" 196 | class: "power" 197 | state_class: "measurement" 198 | uom: "kW" 199 | scale: 0.001 200 | rule: 3 201 | registers: [3005, 3004] 202 | 203 | - name: "Load L1 Voltage" 204 | l: 1 205 | class: "voltage" 206 | state_class: "measurement" 207 | uom: "V" 208 | scale: 0.1 209 | rule: 1 210 | registers: [3033] 211 | icon: "mdi:transmission-tower" 212 | 213 | - name: "Load L2 Voltage" 214 | l: 2 215 | class: "voltage" 216 | state_class: "measurement" 217 | uom: "V" 218 | scale: 0.1 219 | rule: 1 220 | registers: [3034] 221 | icon: "mdi:transmission-tower" 222 | 223 | - name: "Load L3 voltage" 224 | l: 3 225 | class: "voltage" 226 | state_class: "measurement" 227 | uom: "V" 228 | scale: 0.1 229 | rule: 1 230 | registers: [3035] 231 | icon: "mdi:transmission-tower" 232 | 233 | - name: "Load L1 Current" 234 | l: 1 235 | class: "current" 236 | state_class: "measurement" 237 | uom: "A" 238 | scale: 0.1 239 | rule: 1 240 | registers: [3036] 241 | icon: "mdi:current-ac" 242 | 243 | - name: "Load L2 Current" 244 | l: 2 245 | class: "current" 246 | state_class: "measurement" 247 | uom: "A" 248 | scale: 0.1 249 | rule: 1 250 | registers: [3037] 251 | icon: "mdi:current-ac" 252 | 253 | - name: "Load L3 Current" 254 | l: 3 255 | class: "current" 256 | state_class: "measurement" 257 | uom: "A" 258 | scale: 0.1 259 | rule: 1 260 | registers: [3038] 261 | icon: "mdi:current-ac" 262 | 263 | - name: "Load Frequency" 264 | class: "frequency" 265 | state_class: "measurement" 266 | uom: "Hz" 267 | scale: 0.01 268 | rule: 1 269 | registers: [3042] 270 | icon: "mdi:sine-wave" 271 | 272 | - group: Production 273 | items: 274 | - name: "Today Production" 275 | friendly_name: Today's Production 276 | class: "energy" 277 | state_class: "total_increasing" 278 | uom: "kWh" 279 | scale: 0.1 280 | rule: 1 281 | registers: [3014] 282 | icon: "mdi:solar-power" 283 | 284 | - name: "Monthly Production" 285 | class: "energy" 286 | state_class: "total_increasing" 287 | uom: "kWh" 288 | rule: 3 289 | registers: [3011, 3010] 290 | icon: "mdi:solar-power" 291 | 292 | - name: "Yearly Production" 293 | class: "energy" 294 | state_class: "total_increasing" 295 | uom: "kWh" 296 | rule: 3 297 | registers: [3017, 3016] 298 | icon: "mdi:solar-power" 299 | 300 | - name: "Total Production" 301 | class: "energy" 302 | state_class: "total_increasing" 303 | uom: "kWh" 304 | rule: 3 305 | registers: [3009, 3008] 306 | icon: "mdi:solar-power" 307 | validation: 308 | min: 0.1 309 | dev: 100 310 | invalidate_all: 2 311 | -------------------------------------------------------------------------------- /custom_components/solarman/inverter_definitions/solis_s6-gr1p.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # Solis S6-GR1P4.6K 3 | # 4 | # Reference: ??? 5 | # 6 | # NH-Networks 2023 7 | # 8 | 9 | default: 10 | update_interval: 5 11 | code: 0x04 12 | digits: 6 13 | 14 | parameters: 15 | - group: InverterStatus 16 | items: 17 | - name: "Inverter Status" 18 | rule: 1 19 | registers: [3043] 20 | icon: "mdi:home-lightning-bolt" 21 | lookup: 22 | - key: 0x0 23 | value: "Waiting State" 24 | - key: 0x1 25 | value: "Open Loop Operation" 26 | - key: 0x2 27 | value: "Soft Start" 28 | - key: 0x3 29 | value: "On Grid/Generating" 30 | - key: 0x1004 31 | value: "Grid OverVoltage" 32 | - key: 0x1010 33 | value: "Grid UnderVoltage" 34 | - key: 0x1012 35 | value: "Grid OverFrequency" 36 | - key: 0x1013 37 | value: "Grid UnderFrequency" 38 | - key: 0x1014 39 | value: "Grid Imp too large" 40 | - key: 0x1015 41 | value: "No Grid" 42 | - key: 0x1016 43 | value: "Grid Imbalance" 44 | - key: 0x1017 45 | value: "Grid Freq Jitter" 46 | - key: 0x1018 47 | value: "Grid Overcurrent" 48 | - key: 0x1019 49 | value: "Grid Tracking Fault" 50 | - key: 0x1020 51 | value: "DC OverVoltage" 52 | - key: 0x1021 53 | value: "DC Bus Overvoltage" 54 | - key: 0x1022 55 | value: "DC Bus Uneven Voltage" 56 | - key: 0x1024 57 | value: "DC Bus Uneven Voltage2" 58 | - key: 0x1025 59 | value: "DC A path OverCurrent" 60 | - key: 0x1026 61 | value: "DC B path OverCurrent" 62 | - key: 0x1027 63 | value: "DC Input Disturbance" 64 | - key: 0x1030 65 | value: "Grid Disturbance" 66 | - key: 0x1031 67 | value: "DSP Initialization Protection " 68 | - key: 0x1032 69 | value: "Over Temp Protection" 70 | - key: 0x1033 71 | value: "PV Insulation Fault" 72 | - key: 0x1034 73 | value: "Leakage Current Protection" 74 | - key: 0x1035 75 | value: "Relay Detection Protection" 76 | - key: 0x1036 77 | value: "DSP_B Protection" 78 | - key: 0x1037 79 | value: "DC Component too Large" 80 | - key: 0x1038 81 | value: "12v UnderVoltage Protection" 82 | - key: 0x1039 83 | value: "Under Temperature Protection" 84 | - key: 0x1040 85 | value: "Arc Self-Test Protection" 86 | - key: 0x1041 87 | value: "Arc Protection" 88 | - key: 0x1042 89 | value: "DSP on-chip SRAM exception" 90 | - key: 0x1043 91 | value: "DSP on-chip FLASH exception" 92 | - key: 0x1044 93 | value: "DSP on-chip PC pointer is abnormal" 94 | - key: 0x1045 95 | value: "DSP key register exception" 96 | - key: 0x1046 97 | value: "Grid disturbance 02" 98 | - key: 0x1047 99 | value: "Grid current sampling abnormality" 100 | - key: 0x1048 101 | value: "IGBT overcurrent" 102 | - key: 0x1050 103 | value: "Network current transient overcurrent" 104 | - key: 0x1051 105 | value: "Battery overvoltage hardware failure" 106 | - key: 0x1052 107 | value: "LLC hardware overcurrent" 108 | - key: 0x1053 109 | value: "Battery overvoltage detection" 110 | - key: 0x1054 111 | value: "Battery undervoltage detection" 112 | - key: 0x1055 113 | value: "Battery no connected" 114 | - key: 0x1056 115 | value: "Bypass overvoltage fault" 116 | - key: 0x1057 117 | value: "Bypass overload fault" 118 | 119 | - name: "Operating Status" 120 | rule: 1 121 | registers: [3071] 122 | icon: "mdi:home-lightning-bolt" 123 | lookup: 124 | - key: 0x1 125 | value: "Normal Operation" 126 | - key: 0x2 127 | value: "Initial Standby" 128 | - key: 0x4 129 | value: "Control Shutdown" 130 | - key: 0x8 131 | value: "Downtime" 132 | - key: 0x10 133 | value: "Standby" 134 | - key: 0x20 135 | value: "Derating Operation" 136 | - key: 0x40 137 | value: "Limit Operation" 138 | - key: 0x80 139 | value: "Bypass Overload" 140 | 141 | - name: "Inverter Temperature" 142 | class: "temperature" 143 | state_class: "measurement" 144 | uom: "°C" 145 | scale: 0.1 146 | rule: 2 147 | registers: [3041] 148 | icon: "mdi:thermometer" 149 | 150 | # Registers below are outside of modbus request ranges. 151 | # If enabling, ensure to amend the request start register. 152 | # - name: "Inverter ID" 153 | # rule: 5 154 | # registers: [33004,33005,33006,33007,33008,33009,33010,33011,33012,33013,33014,33015,33016,33017,33018,33019] 155 | 156 | # - name: "Product Model" 157 | # rule: 6 158 | # registers: [2999] 159 | 160 | # - name: "DSP Software Version" 161 | # rule: 6 162 | # registers: [3000] 163 | 164 | # - name: "LCD Software Version" 165 | # rule: 6 166 | # registers: [3001] 167 | 168 | - group: PV 169 | items: 170 | - name: "PV Power" 171 | class: "power" 172 | mppt: 1 173 | state_class: "measurement" 174 | uom: "kW" 175 | scale: 0.001 176 | rule: 3 177 | registers: [3007, 3006] 178 | icon: "mdi:solar-power" 179 | 180 | - name: "PV1 Voltage" 181 | class: "voltage" 182 | mppt: 1 183 | state_class: "measurement" 184 | uom: "V" 185 | scale: 0.1 186 | rule: 1 187 | registers: [3021] 188 | icon: "mdi:solar-power" 189 | 190 | - name: "PV1 Current" 191 | class: "current" 192 | mppt: 1 193 | state_class: "measurement" 194 | uom: "A" 195 | scale: 0.1 196 | rule: 1 197 | registers: [3022] 198 | icon: "mdi:current-dc" 199 | 200 | - name: "PV1 Power" 201 | class: "power" 202 | mppt: 1 203 | state_class: "measurement" 204 | uom: "kW" 205 | rule: 1 206 | digits: 3 207 | registers: [3021, 3022] 208 | sensors: 209 | - registers: [3021] 210 | scale: 0.1 211 | - operator: multiply 212 | scale: 0.0001 213 | registers: [3022] 214 | 215 | - name: "PV2 Voltage" 216 | class: "voltage" 217 | mppt: 2 218 | state_class: "measurement" 219 | uom: "V" 220 | scale: 0.1 221 | rule: 1 222 | registers: [3023] 223 | icon: "mdi:solar-power" 224 | 225 | - name: "PV2 Current" 226 | class: "current" 227 | mppt: 2 228 | state_class: "measurement" 229 | uom: "A" 230 | scale: 0.1 231 | rule: 1 232 | registers: [3024] 233 | icon: "mdi:current-dc" 234 | 235 | - name: "PV2 Power" 236 | class: "power" 237 | mppt: 2 238 | state_class: "measurement" 239 | uom: "kW" 240 | rule: 1 241 | digits: 3 242 | registers: [3023, 3024] 243 | sensors: 244 | - registers: [3023] 245 | scale: 0.1 246 | - operator: multiply 247 | scale: 0.0001 248 | registers: [3024] 249 | 250 | - group: Load 251 | items: 252 | - name: "Load Power" 253 | class: "power" 254 | state_class: "measurement" 255 | uom: "W" 256 | rule: 1 257 | registers: [3005, 3004] 258 | icon: "mdi:solar-power" 259 | 260 | - name: "Load Voltage" 261 | class: "voltage" 262 | state_class: "measurement" 263 | uom: "V" 264 | scale: 0.1 265 | rule: 1 266 | registers: [3035] 267 | icon: "mdi:transmission-tower" 268 | 269 | - name: "Load Current" 270 | class: "current" 271 | state_class: "measurement" 272 | uom: "A" 273 | scale: 0.1 274 | rule: 1 275 | registers: [3038] 276 | icon: "mdi:current-ac" 277 | 278 | - name: "Load Frequency" 279 | class: "frequency" 280 | state_class: "measurement" 281 | uom: "Hz" 282 | scale: 0.01 283 | rule: 1 284 | registers: [3042] 285 | icon: "mdi:sine-wave" 286 | 287 | - group: Production 288 | items: 289 | - name: "Today Production" 290 | friendly_name: Today's Production 291 | class: "energy" 292 | state_class: "measurement" 293 | uom: "kWh" 294 | scale: 0.1 295 | rule: 1 296 | registers: [3014] 297 | icon: "mdi:solar-power" 298 | 299 | - name: "Monthly Production" 300 | class: "energy" 301 | state_class: "total_increasing" 302 | uom: "kWh" 303 | rule: 3 304 | registers: [3011, 3010] 305 | icon: "mdi:solar-power" 306 | 307 | - name: "Yearly Production" 308 | class: "energy" 309 | state_class: "total_increasing" 310 | uom: "kWh" 311 | rule: 3 312 | registers: [3017, 3016] 313 | icon: "mdi:solar-power" 314 | 315 | - name: "Total Production" 316 | class: "energy" 317 | state_class: "total_increasing" 318 | uom: "kWh" 319 | rule: 3 320 | registers: [3009, 3008] 321 | icon: "mdi:solar-power" 322 | validation: 323 | min: 0.1 324 | dev: 100 325 | invalidate_all: 2 326 | -------------------------------------------------------------------------------- /custom_components/solarman/inverter_definitions/tsun_tsol-ms.yaml: -------------------------------------------------------------------------------- 1 | # 2 | # TSUN TSOL-MS300~800 | 2 MPPT | Microinverter 3 | # 4 | # Reference: https://github.com/davidrapan/ha-solarman/issues/153 5 | # 6 | # Tested with TSUN TSOL-MS800 (Serial: Y47E...) FW: LSW5BLE_17_02B0_1.08-D1 7 | # 8 | 9 | default: 10 | update_interval: 5 11 | digits: 6 12 | 13 | parameters: 14 | - group: PV 15 | items: 16 | - name: "PV1 Voltage" 17 | class: "voltage" 18 | mppt: 1 19 | state_class: "measurement" 20 | uom: "V" 21 | scale: 0.1 22 | rule: 1 23 | registers: [0x3010] 24 | icon: "mdi:solar-power" 25 | 26 | - name: "PV1 Current" 27 | class: "current" 28 | mppt: 1 29 | state_class: "measurement" 30 | uom: "A" 31 | scale: 0.01 32 | rule: 1 33 | registers: [0x3011] 34 | icon: "mdi:solar-power" 35 | 36 | - name: PV1 Power 37 | class: "power" 38 | mppt: 1 39 | state_class: "measurement" 40 | uom: "W" 41 | rule: 1 42 | scale: 0.1 43 | registers: [0x3012] 44 | 45 | - name: "PV2 Voltage" 46 | class: "voltage" 47 | mppt: 2 48 | state_class: "measurement" 49 | uom: "V" 50 | scale: 0.1 51 | rule: 1 52 | registers: [0x3013] 53 | icon: "mdi:solar-power" 54 | 55 | - name: "PV2 Current" 56 | class: "current" 57 | mppt: 2 58 | state_class: "measurement" 59 | uom: "A" 60 | scale: 0.01 61 | rule: 1 62 | registers: [0x3014] 63 | icon: "mdi:solar-power" 64 | 65 | - name: PV2 Power 66 | class: "power" 67 | mppt: 2 68 | state_class: "measurement" 69 | uom: "W" 70 | rule: 1 71 | scale: 0.1 72 | registers: [0x3015] 73 | 74 | - name: "Today Production" 75 | friendly_name: Today's Production 76 | alt: Daily Production 77 | class: "energy" 78 | state_class: "total_increasing" 79 | uom: "kWh" 80 | scale: 0.01 81 | rule: 1 82 | registers: [0x301c] 83 | icon: "mdi:solar-power" 84 | 85 | - name: "Today Production 1" 86 | friendly_name: Today's Production 1 87 | alt: Daily Production 1 88 | class: "energy" 89 | state_class: "total_increasing" 90 | uom: "kWh" 91 | scale: 0.1 92 | rule: 1 93 | registers: [0x301f] 94 | icon: "mdi:solar-power" 95 | 96 | - name: "Today Production 2" 97 | friendly_name: Today's Production 2 98 | alt: "Daily Production 2" 99 | class: "energy" 100 | state_class: "total_increasing" 101 | uom: "kWh" 102 | scale: 0.01 103 | rule: 1 104 | registers: [0x3022] 105 | icon: "mdi:solar-power" 106 | 107 | - name: "Total Production" 108 | class: "energy" 109 | state_class: "total_increasing" 110 | uom: "kWh" 111 | scale: 0.01 112 | rule: 1 113 | registers: [0x301e] 114 | icon: "mdi:solar-power" 115 | validation: 116 | min: 0.1 117 | dev: 100 118 | invalidate_all: 2 119 | 120 | - group: Grid 121 | items: 122 | - name: "Grid Voltage" 123 | class: "voltage" 124 | state_class: "measurement" 125 | uom: "V" 126 | scale: 0.1 127 | rule: 1 128 | registers: [0x3009] 129 | icon: "mdi:transmission-tower" 130 | 131 | - name: "Grid Current" 132 | class: "current" 133 | state_class: "measurement" 134 | uom: "A" 135 | scale: 0.01 136 | rule: 2 137 | registers: [0x300a] 138 | icon: "mdi:transmission-tower" 139 | 140 | - name: "Grid Frequency" 141 | class: "frequency" 142 | state_class: "measurement" 143 | uom: "Hz" 144 | scale: 0.01 145 | rule: 1 146 | registers: [0x300b] 147 | icon: "mdi:transmission-tower" 148 | 149 | - group: Device 150 | items: 151 | - name: "Device State" 152 | class: "enum" 153 | rule: 1 154 | registers: [0x3000] 155 | icon: "mdi:state-machine" 156 | range: 157 | min: 0 158 | max: 5 159 | lookup: 160 | - key: 0x0000 161 | value: "Standby" 162 | - key: 0x0001 163 | value: "Normal" 164 | 165 | - name: "Temperature" 166 | class: "temperature" 167 | uom: "°C" 168 | state_class: "measurement" 169 | rule: 1 170 | offset: 40 171 | registers: [0x300c] 172 | 173 | - name: "PV Max Power" 174 | class: "power" 175 | mppt: 1 176 | state_class: "measurement" 177 | uom: "W" 178 | rule: 1 179 | registers: [0x2007] 180 | 181 | - name: "Rated Power" 182 | class: "power" 183 | state_class: "measurement" 184 | uom: "W" 185 | rule: 1 186 | registers: [0x300e] 187 | 188 | - name: "Output Power" 189 | class: "power" 190 | state_class: "measurement" 191 | uom: "W" 192 | scale: 0.1 193 | rule: 3 194 | registers: [0x300f] 195 | -------------------------------------------------------------------------------- /custom_components/solarman/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "solarman", 3 | "name": "Solarman", 4 | "codeowners": ["@davidrapan"], 5 | "config_flow": true, 6 | "dependencies": ["network", "dhcp"], 7 | "dhcp": [{"macaddress": "E8FDF8*"}, {"registered_devices": true}], 8 | "documentation": "https://github.com/davidrapan/ha-solarman", 9 | "integration_type": "device", 10 | "iot_class": "local_polling", 11 | "issue_tracker": "https://github.com/davidrapan/ha-solarman/issues", 12 | "quality_scale": "custom", 13 | "requirements": ["propcache", "aiofiles", "pyyaml"], 14 | "version": "25.06.06" 15 | } 16 | -------------------------------------------------------------------------------- /custom_components/solarman/number.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from typing import Any 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.components.number import NumberEntity, NumberDeviceClass, NumberEntityDescription 9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 10 | 11 | from .const import * 12 | from .common import * 13 | from .services import * 14 | from .entity import SolarmanConfigEntry, create_entity, SolarmanWritableEntity 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | _PLATFORM = get_current_file_name(__name__) 19 | 20 | async def async_setup_entry(_: HomeAssistant, config_entry: SolarmanConfigEntry, async_add_entities: AddEntitiesCallback) -> bool: 21 | _LOGGER.debug(f"async_setup_entry: {config_entry.options}") 22 | 23 | async_add_entities(create_entity(lambda x: SolarmanNumberEntity(config_entry.runtime_data, x), d) for d in postprocess_descriptions(config_entry.runtime_data, _PLATFORM)) 24 | 25 | return True 26 | 27 | async def async_unload_entry(_: HomeAssistant, config_entry: SolarmanConfigEntry) -> bool: 28 | _LOGGER.debug(f"async_unload_entry: {config_entry.options}") 29 | 30 | return True 31 | 32 | class SolarmanNumberEntity(SolarmanWritableEntity, NumberEntity): 33 | def __init__(self, coordinator, sensor): 34 | SolarmanWritableEntity.__init__(self, coordinator, sensor) 35 | 36 | if "mode" in sensor and (mode := sensor["mode"]): 37 | self._attr_mode = mode 38 | 39 | self.scale = None 40 | if "scale" in sensor: 41 | self.scale = get_number(sensor["scale"]) 42 | 43 | self.offset = None 44 | if "offset" in sensor: 45 | self.offset = get_number(sensor["offset"]) 46 | 47 | if "configurable" in sensor and (configurable := sensor["configurable"]): 48 | if "mode" in configurable: 49 | self._attr_mode = configurable["mode"] 50 | if "min" in configurable: 51 | self._attr_native_min_value = configurable["min"] 52 | if "max" in configurable: 53 | self._attr_native_max_value = configurable["max"] 54 | if "step" in configurable: 55 | self._attr_native_step = configurable["step"] 56 | 57 | if not hasattr(self, "_attr_native_min_value") and not hasattr(self, "_attr_native_max_value") and "range" in sensor and (range := sensor["range"]): 58 | self._attr_native_min_value = range["min"] 59 | self._attr_native_max_value = range["max"] 60 | if self.scale is not None: 61 | self._attr_native_min_value *= self.scale 62 | self._attr_native_max_value *= self.scale 63 | 64 | async def async_set_native_value(self, value: float) -> None: 65 | """Update the setting.""" 66 | value_int = int(value if self.scale is None else value / self.scale) 67 | if self.offset is not None: 68 | value_int += self.offset 69 | await self.write(value_int if value_int < 0xFFFF else 0xFFFF, get_number(value)) 70 | -------------------------------------------------------------------------------- /custom_components/solarman/provider.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import socket 4 | import logging 5 | 6 | from typing import Any 7 | from dataclasses import dataclass 8 | from propcache import cached_property 9 | from collections.abc import Awaitable, Callable 10 | from ipaddress import IPv4Address, AddressValueError 11 | 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.config_entries import ConfigEntry 14 | 15 | from .const import * 16 | from .common import * 17 | from .discovery import Discovery 18 | from .parser import ParameterParser 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | @dataclass 23 | class ConfigurationProvider: 24 | hass: HomeAssistant 25 | config_entry: ConfigEntry 26 | 27 | @cached_property 28 | def _options(self): 29 | return self.config_entry.options 30 | 31 | @cached_property 32 | def _additional_options(self): 33 | return self._options.get(CONF_ADDITIONAL_OPTIONS, {}) 34 | 35 | @cached_property 36 | def name(self): 37 | return protected(self.config_entry.title, "Configuration parameter [title] does not have a value") 38 | 39 | @cached_property 40 | def host(self): 41 | return protected(self._options.get(CONF_HOST), "Configuration parameter [host] does not have a value") 42 | 43 | @cached_property 44 | def port(self): 45 | return self._options.get(CONF_PORT, DEFAULT_[CONF_PORT]) 46 | 47 | @cached_property 48 | def transport(self): 49 | return self._options.get(CONF_TRANSPORT, DEFAULT_[CONF_TRANSPORT]) 50 | 51 | @cached_property 52 | def filename(self): 53 | return self._options.get(CONF_LOOKUP_FILE, DEFAULT_[CONF_LOOKUP_FILE]) 54 | 55 | @cached_property 56 | def mb_slave_id(self): 57 | return self._additional_options.get(CONF_MB_SLAVE_ID, DEFAULT_[CONF_MB_SLAVE_ID]) 58 | 59 | @cached_property 60 | def directory(self): 61 | return self.hass.config.path(LOOKUP_DIRECTORY_PATH) 62 | 63 | @dataclass 64 | class EndPointProvider: 65 | config: ConfigurationProvider 66 | mac = "" 67 | serial = 0 68 | 69 | def __getattr__(self, attr: str) -> Any: 70 | return getattr(self.config, attr) 71 | 72 | @cached_property 73 | def host(self): 74 | return self.config.host 75 | 76 | @cached_property 77 | def connection(self): 78 | return self.host, self.port, self.transport, self.serial, self.mb_slave_id, TIMINGS_INTERVAL 79 | 80 | @cached_property 81 | def ip(self): 82 | try: 83 | return IPv4Address(self.host) 84 | except AddressValueError: 85 | return IPv4Address(socket.gethostbyname(self.host)) 86 | 87 | async def discover(self): 88 | if self.ip.is_private and (discover := await Discovery(self.hass).discover(str(self.ip))): 89 | if (device := discover.get((s := next(iter([k for k, v in discover.items() if v["ip"] == str(self.ip)]), None)))) is not None: 90 | self.host = device["ip"] 91 | self.mac = device["mac"] 92 | self.serial = s 93 | return self 94 | 95 | @dataclass 96 | class ProfileProvider: 97 | config: ConfigurationProvider 98 | endpoint: EndPointProvider 99 | parser: ParameterParser | None = None 100 | info: dict[str, str] | None = None 101 | 102 | def __getattr__(self, attr: str) -> Any: 103 | return getattr(self.config, attr) 104 | 105 | @cached_property 106 | def auto(self) -> bool: 107 | return not self.filename or self.filename in AUTODETECTION_REDIRECT 108 | 109 | @cached_property 110 | def parameters(self) -> str: 111 | return {PARAM_[k]: int(self._additional_options.get(k, DEFAULT_[k])) for k in PARAM_} 112 | 113 | async def resolve(self, request: Callable[[], Awaitable[None]] | None = None): 114 | _LOGGER.debug(f"Device autodetection is {"enabled" if self.auto and request else f"disabled. Selected profile: {self.filename}"}") 115 | if (f := await lookup_profile(request, self.parameters) if self.auto and request else self.filename) and f != DEFAULT_[CONF_LOOKUP_FILE] and (n := process_profile(f, self.parameters)) and (p := await yaml_open(self.config.directory + n)): 116 | self.parser = ParameterParser(p, self.parameters) 117 | self.info = (unwrap(p["info"], "model", self.parameters[PARAM_[CONF_MOD]]) if "info" in p else {}) | {"filename": f} 118 | -------------------------------------------------------------------------------- /custom_components/solarman/pysolarman/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidrapan/ha-solarman/c5547ca9353cffb316c645d45a439dcaec64d254/custom_components/solarman/pysolarman/__init__.py -------------------------------------------------------------------------------- /custom_components/solarman/pysolarman/license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © 2021 Jonathan McCrohan 4 | Copyright © 2024 githubDante 5 | Copyright © 2024 David Rapan 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | -------------------------------------------------------------------------------- /custom_components/solarman/pysolarman/umodbus/__init__.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger, NullHandler 2 | 3 | log = getLogger('uModbus') 4 | log.addHandler(NullHandler()) 5 | 6 | from .config import Config # NOQA 7 | conf = Config() 8 | -------------------------------------------------------------------------------- /custom_components/solarman/pysolarman/umodbus/client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidrapan/ha-solarman/c5547ca9353cffb316c645d45a439dcaec64d254/custom_components/solarman/pysolarman/umodbus/client/__init__.py -------------------------------------------------------------------------------- /custom_components/solarman/pysolarman/umodbus/client/serial/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidrapan/ha-solarman/c5547ca9353cffb316c645d45a439dcaec64d254/custom_components/solarman/pysolarman/umodbus/client/serial/__init__.py -------------------------------------------------------------------------------- /custom_components/solarman/pysolarman/umodbus/client/serial/redundancy_check.py: -------------------------------------------------------------------------------- 1 | """ CRC is calculated over slave id + PDU. 2 | 3 | Most code is taken from: https://github.com/pyhys/minimalmodbus/blob/e99f4d74c83258c6039073082955ac9bed3f2155/minimalmodbus.py # NOQA 4 | """ 5 | import struct 6 | 7 | 8 | def generate_look_up_table(): 9 | """ Generate look up table. 10 | 11 | :return: List 12 | """ 13 | poly = 0xA001 14 | table = [] 15 | 16 | for index in range(256): 17 | 18 | data = index << 1 19 | crc = 0 20 | for _ in range(8, 0, -1): 21 | data >>= 1 22 | if (data ^ crc) & 0x0001: 23 | crc = (crc >> 1) ^ poly 24 | else: 25 | crc >>= 1 26 | table.append(crc) 27 | 28 | return table 29 | 30 | 31 | look_up_table = generate_look_up_table() 32 | 33 | 34 | def get_crc(msg): 35 | """ Return CRC of 2 byte for message. 36 | 37 | >>> assert get_crc(b'\x02\x07') == struct.unpack('> 8) ^ look_up_table[(register ^ val) & 0xFF] 54 | 55 | # CRC is little-endian! 56 | return struct.pack(' `b\x01\x00d` 33 | 34 | .. code-block:: python 35 | 36 | >>> # Read coils, starting from coil 100 for the length of 3 coils. 37 | >>> adu = b'\\x01\\x01\\x00d\\x00\\x03=\\xd4' 38 | 39 | The lenght of this ADU is 8 bytes:: 40 | 41 | >>> len(adu) 42 | 8 43 | 44 | """ 45 | import struct 46 | 47 | from .redundancy_check import get_crc, validate_crc 48 | from ...functions import (FUNCTION_CODE, create_function_from_response_pdu, 49 | expected_response_pdu_size_from_request_pdu, 50 | pdu_to_function_code_or_raise_error, ReadCoils, 51 | ReadDiscreteInputs, ReadHoldingRegisters, 52 | ReadInputRegisters, WriteSingleCoil, 53 | WriteSingleRegister, WriteMultipleCoils, 54 | WriteMultipleRegisters) 55 | from ...utils import recv_exactly 56 | 57 | 58 | def _create_request_adu(slave_id, req_pdu): 59 | """ Return request ADU for Modbus RTU. 60 | 61 | :param slave_id: Slave id. 62 | :param req_pdu: Byte array with PDU. 63 | :return: Byte array with ADU. 64 | """ 65 | first_part_adu = struct.pack('>B', slave_id) + req_pdu 66 | 67 | return first_part_adu + get_crc(first_part_adu) 68 | 69 | 70 | def read_coils(slave_id, starting_address, quantity): 71 | """ Return ADU for Modbus function code 01: Read Coils. 72 | 73 | :param slave_id: Number of slave. 74 | :return: Byte array with ADU. 75 | """ 76 | function = ReadCoils() 77 | function.starting_address = starting_address 78 | function.quantity = quantity 79 | 80 | return _create_request_adu(slave_id, function.request_pdu) 81 | 82 | 83 | def read_discrete_inputs(slave_id, starting_address, quantity): 84 | """ Return ADU for Modbus function code 02: Read Discrete Inputs. 85 | 86 | :param slave_id: Number of slave. 87 | :return: Byte array with ADU. 88 | """ 89 | function = ReadDiscreteInputs() 90 | function.starting_address = starting_address 91 | function.quantity = quantity 92 | 93 | return _create_request_adu(slave_id, function.request_pdu) 94 | 95 | 96 | def read_holding_registers(slave_id, starting_address, quantity): 97 | """ Return ADU for Modbus function code 03: Read Holding Registers. 98 | 99 | :param slave_id: Number of slave. 100 | :return: Byte array with ADU. 101 | """ 102 | function = ReadHoldingRegisters() 103 | function.starting_address = starting_address 104 | function.quantity = quantity 105 | 106 | return _create_request_adu(slave_id, function.request_pdu) 107 | 108 | 109 | def read_input_registers(slave_id, starting_address, quantity): 110 | """ Return ADU for Modbus function code 04: Read Input Registers. 111 | 112 | :param slave_id: Number of slave. 113 | :return: Byte array with ADU. 114 | """ 115 | function = ReadInputRegisters() 116 | function.starting_address = starting_address 117 | function.quantity = quantity 118 | 119 | return _create_request_adu(slave_id, function.request_pdu) 120 | 121 | 122 | def write_single_coil(slave_id, address, value): 123 | """ Return ADU for Modbus function code 05: Write Single Coil. 124 | 125 | :param slave_id: Number of slave. 126 | :return: Byte array with ADU. 127 | """ 128 | function = WriteSingleCoil() 129 | function.address = address 130 | function.value = value 131 | 132 | return _create_request_adu(slave_id, function.request_pdu) 133 | 134 | 135 | def write_single_register(slave_id, address, value): 136 | """ Return ADU for Modbus function code 06: Write Single Register. 137 | 138 | :param slave_id: Number of slave. 139 | :return: Byte array with ADU. 140 | """ 141 | function = WriteSingleRegister() 142 | function.address = address 143 | function.value = value 144 | 145 | return _create_request_adu(slave_id, function.request_pdu) 146 | 147 | 148 | def write_multiple_coils(slave_id, starting_address, values): 149 | """ Return ADU for Modbus function code 15: Write Multiple Coils. 150 | 151 | :param slave_id: Number of slave. 152 | :return: Byte array with ADU. 153 | """ 154 | function = WriteMultipleCoils() 155 | function.starting_address = starting_address 156 | function.values = values 157 | 158 | return _create_request_adu(slave_id, function.request_pdu) 159 | 160 | 161 | def write_multiple_registers(slave_id, starting_address, values): 162 | """ Return ADU for Modbus function code 16: Write Multiple Registers. 163 | 164 | :param slave_id: Number of slave. 165 | :return: Byte array with ADU. 166 | """ 167 | function = WriteMultipleRegisters() 168 | function.starting_address = starting_address 169 | function.values = values 170 | 171 | return _create_request_adu(slave_id, function.request_pdu) 172 | 173 | 174 | def parse_response_adu(resp_adu, req_adu=None): 175 | """ Parse response ADU and return response data. Some functions require 176 | request ADU to fully understand request ADU. 177 | 178 | :param resp_adu: Resonse ADU. 179 | :param req_adu: Request ADU, default None. 180 | :return: Response data. 181 | """ 182 | resp_pdu = resp_adu[1:-2] 183 | validate_crc(resp_adu) 184 | 185 | req_pdu = None 186 | 187 | if req_adu is not None: 188 | req_pdu = req_adu[1:-2] 189 | 190 | function = create_function_from_response_pdu(resp_pdu, req_pdu) 191 | 192 | return function.data 193 | 194 | 195 | def raise_for_exception_adu(resp_adu): 196 | """ Check a response ADU for error 197 | 198 | :param resp_adu: Response ADU. 199 | :raises ModbusError: When a response contains an error code. 200 | """ 201 | resp_pdu = resp_adu[1:-2] 202 | pdu_to_function_code_or_raise_error(resp_pdu) 203 | 204 | 205 | def send_message(adu, serial_port): 206 | """ Send ADU over serial to to server and return parsed response. 207 | 208 | :param adu: Request ADU. 209 | :param sock: Serial port instance. 210 | :return: Parsed response from server. 211 | """ 212 | serial_port.write(adu) 213 | serial_port.flush() 214 | 215 | # Check exception ADU (which is shorter than all other responses) first. 216 | exception_adu_size = 5 217 | response_error_adu = recv_exactly(serial_port.read, exception_adu_size) 218 | raise_for_exception_adu(response_error_adu) 219 | 220 | expected_response_size = \ 221 | expected_response_pdu_size_from_request_pdu(adu[1:-2]) + 3 222 | response_remainder = recv_exactly( 223 | serial_port.read, expected_response_size - exception_adu_size) 224 | 225 | return parse_response_adu(response_error_adu + response_remainder, adu) 226 | 227 | 228 | function_code_to_function_map = { 229 | FUNCTION_CODE.READ_COILS: lambda slave_id, address, count, **kwargs: read_coils(slave_id, address, count), 230 | FUNCTION_CODE.READ_DISCRETE_INPUTS: lambda slave_id, address, count, **kwargs: read_discrete_inputs(slave_id, address, count), 231 | FUNCTION_CODE.READ_HOLDING_REGISTERS: lambda slave_id, address, count, **kwargs: read_holding_registers(slave_id, address, count), 232 | FUNCTION_CODE.READ_INPUT_REGISTERS: lambda slave_id, address, count, **kwargs: read_input_registers(slave_id, address, count), 233 | FUNCTION_CODE.WRITE_SINGLE_COIL: lambda slave_id, address, data, **kwargs: write_single_coil(slave_id, address, data), 234 | FUNCTION_CODE.WRITE_SINGLE_REGISTER: lambda slave_id, address, data, **kwargs: write_single_register(slave_id, address, data), 235 | FUNCTION_CODE.WRITE_MULTIPLE_COILS: lambda slave_id, address, data, **kwargs: write_multiple_coils(slave_id, address, data), 236 | FUNCTION_CODE.WRITE_MULTIPLE_REGISTERS: lambda slave_id, address, data, **kwargs: write_multiple_registers(slave_id, address, data) 237 | } 238 | -------------------------------------------------------------------------------- /custom_components/solarman/pysolarman/umodbus/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Config(object): 5 | """ Class to hold global configuration. """ 6 | 7 | SINGLE_BIT_VALUE_FORMAT_CHARACTER = 'B' 8 | """ Format character used to (un)pack singlebit values (values used for 9 | writing from and writing to coils or discrete inputs) from structs. 10 | 11 | .. note:: Its value should not be changed. This attribute exists to be 12 | consistend with `MULTI_BIT_VALUE_FORMAT_CHARACTER`. 13 | """ 14 | 15 | MULTI_BIT_VALUE_FORMAT_CHARACTER = 'H' 16 | """ Format character used to (un)pack multibit values (values used for 17 | writing from and writing to registers) from structs. 18 | 19 | The format character depends on size of the value and whether values are 20 | signed or unsigned. 21 | 22 | By default multibit values are unsigned and use 16 bits. The default format 23 | character used for (un)packing structs is 'H'. 24 | 25 | .. note:: Its value should not be set directly. Instead use 26 | :attr:`SIGNED_VALUES` and :attr:`BIT_SIZE` to 27 | modify this value. 28 | 29 | """ 30 | def __init__(self): 31 | self.SIGNED_VALUES = os.environ.get('UMODBUS_SIGNED_VALUES', False) 32 | self.BIT_SIZE = os.environ.get('UMODBUS_BIT_SIZE', 16) 33 | 34 | @property 35 | def TYPE_CHAR(self): 36 | if self.SIGNED_VALUES: 37 | return 'h' 38 | 39 | return 'H' 40 | 41 | def _set_multi_bit_value_format_character(self): 42 | """ Set format character for multibit values. 43 | 44 | The format character depends on size of the value and whether values 45 | are signed or unsigned. 46 | 47 | """ 48 | self.MULTI_BIT_VALUE_FORMAT_CHARACTER = \ 49 | self.MULTI_BIT_VALUE_FORMAT_CHARACTER.upper() 50 | 51 | if self.SIGNED_VALUES: 52 | self.MULTI_BIT_VALUE_FORMAT_CHARACTER = \ 53 | self.MULTI_BIT_VALUE_FORMAT_CHARACTER.lower() 54 | 55 | @property 56 | def SIGNED_VALUES(self): 57 | """ Whether values are signed or not. Default is False. 58 | 59 | This value can also be set using the environment variable 60 | `UMODBUS_SIGNED_VALUES`. 61 | """ 62 | return self._SIGNED_VALUES 63 | 64 | @SIGNED_VALUES.setter 65 | def SIGNED_VALUES(self, value): 66 | """ Set signedness of values. 67 | 68 | This method effects `Config.MULTI_BIT_VALUE_FORMAT_CHARACTER`. 69 | :param value: Boolean indicting if values are signed or not. 70 | """ 71 | self._SIGNED_VALUES = value 72 | self._set_multi_bit_value_format_character() 73 | 74 | @property 75 | def BIT_SIZE(self): 76 | """ Bit size of values. Default is 16. 77 | 78 | This value can also be set using the environment variable 79 | `UMODBUS_BIT_SIZE`. 80 | """ 81 | return self._BIT_SIZE 82 | 83 | @BIT_SIZE.setter 84 | def BIT_SIZE(self, value): 85 | """ Set bit size of values. 86 | 87 | This method effects `Config.MULTI_BIT_VALUE_FORMAT_CHARACTER`. 88 | :param value: Number indication bit size. 89 | """ 90 | self._BIT_SIZE = value 91 | self._set_multi_bit_value_format_character() 92 | -------------------------------------------------------------------------------- /custom_components/solarman/pysolarman/umodbus/exceptions.py: -------------------------------------------------------------------------------- 1 | class ModbusError(Exception): 2 | """ Base class for all Modbus related exception. """ 3 | pass 4 | 5 | 6 | class IllegalFunctionError(ModbusError): 7 | """ The function code received in the request is not an allowable action for 8 | the server. 9 | 10 | """ 11 | error_code = 1 12 | 13 | def __str__(self): 14 | return 'Function code is not an allowable action for the server.' 15 | 16 | 17 | class IllegalDataAddressError(ModbusError): 18 | """ The data address received in the request is not an allowable address for 19 | the server. 20 | """ 21 | error_code = 2 22 | 23 | def __str__(self): 24 | return self.__doc__ 25 | 26 | 27 | class IllegalDataValueError(ModbusError): 28 | """ The value contained in the request data field is not an allowable value 29 | for the server. 30 | 31 | """ 32 | error_code = 3 33 | 34 | def __str__(self): 35 | return self.__doc__ 36 | 37 | 38 | class ServerDeviceFailureError(ModbusError): 39 | """ An unrecoverable error occurred. """ 40 | error_code = 4 41 | 42 | def __str__(self): 43 | return 'An unrecoverable error occurred.' 44 | 45 | 46 | class AcknowledgeError(ModbusError): 47 | """ The server has accepted the requests and it processing it, but a long 48 | duration of time will be required to do so. 49 | """ 50 | error_code = 5 51 | 52 | def __str__(self): 53 | return self.__doc__ 54 | 55 | 56 | class ServerDeviceBusyError(ModbusError): 57 | """ The server is engaged in a long-duration program command. """ 58 | error_code = 6 59 | 60 | def __str__(self): 61 | return self.__doc__ 62 | 63 | 64 | class NegativeAcknowledgeError(ModbusError): 65 | """ The server cannot perform the program function received in the query. 66 | This code is returned for an unsuccessful programming request. 67 | """ 68 | error_code = 7 69 | 70 | def __str__(self): 71 | return self.__doc__ 72 | 73 | 74 | class MemoryParityError(ModbusError): 75 | """ The server attempted to read record file, but detected a parity error 76 | in memory. 77 | """ 78 | error_code = 8 79 | 80 | def __repr__(self): 81 | return self.__doc__ 82 | 83 | 84 | class GatewayPathUnavailableError(ModbusError): 85 | """ The gateway is probably misconfigured or overloaded. """ 86 | error_code = 10 87 | 88 | def __repr__(self): 89 | return self.__doc__ 90 | 91 | 92 | class GatewayTargetDeviceFailedToRespondError(ModbusError): 93 | """ Didn't get a response from target device. """ 94 | error_code = 11 95 | 96 | def __repr__(self): 97 | return self.__doc__ 98 | 99 | error_code_to_exception_map = { 100 | IllegalFunctionError.error_code: IllegalFunctionError, 101 | IllegalDataAddressError.error_code: IllegalDataAddressError, 102 | IllegalDataValueError.error_code: IllegalDataValueError, 103 | ServerDeviceFailureError.error_code: ServerDeviceFailureError, 104 | AcknowledgeError.error_code: AcknowledgeError, 105 | ServerDeviceBusyError.error_code: ServerDeviceBusyError, 106 | NegativeAcknowledgeError.error_code: NegativeAcknowledgeError, 107 | MemoryParityError.error_code: MemoryParityError, 108 | GatewayPathUnavailableError.error_code: GatewayPathUnavailableError, 109 | GatewayTargetDeviceFailedToRespondError.error_code: 110 | GatewayTargetDeviceFailedToRespondError 111 | } 112 | -------------------------------------------------------------------------------- /custom_components/solarman/pysolarman/umodbus/route.py: -------------------------------------------------------------------------------- 1 | class Map: 2 | def __init__(self): 3 | self._rules = [] 4 | 5 | def add_rule(self, endpoint, slave_ids, function_codes, addresses): 6 | self._rules.append(DataRule(endpoint, slave_ids, function_codes, 7 | addresses)) 8 | 9 | def match(self, slave_id, function_code, address): 10 | for rule in self._rules: 11 | if rule.match(slave_id, function_code, address): 12 | return rule.endpoint 13 | 14 | 15 | class DataRule: 16 | def __init__(self, endpoint, slave_ids, function_codes, addresses): 17 | self.endpoint = endpoint 18 | self.slave_ids = slave_ids 19 | self.function_codes = function_codes 20 | self.addresses = addresses 21 | 22 | def match(self, slave_id, function_code, address): 23 | # A constraint of None matches any value 24 | matches = lambda values, v: values is None or v in values 25 | return matches(self.slave_ids, slave_id) and \ 26 | matches(self.function_codes, function_code) and \ 27 | matches(self.addresses, address) 28 | -------------------------------------------------------------------------------- /custom_components/solarman/pysolarman/umodbus/server/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from socketserver import BaseRequestHandler 3 | except ImportError: 4 | from SocketServer import BaseRequestHandler # type: ignore 5 | from binascii import hexlify 6 | 7 | from . import log 8 | from ..functions import create_function_from_request_pdu 9 | from ..exceptions import ModbusError, ServerDeviceFailureError 10 | from ..utils import (get_function_code_from_request_pdu, 11 | pack_exception_pdu, recv_exactly) 12 | 13 | 14 | def route(self, slave_ids=None, function_codes=None, addresses=None): 15 | """ A decorator that is used to register an endpoint for a given 16 | rule:: 17 | 18 | @server.route(slave_ids=[1], function_codes=[1, 2], addresses=list(range(100, 200))) # NOQA 19 | def read_single_bit_values(slave_id, address): 20 | return random.choise([0, 1]) 21 | 22 | Any argument can be omitted to match any value. 23 | 24 | :param slave_ids: A list (or iterable) of slave ids. 25 | :param function_codes: A list (or iterable) of function codes. 26 | :param addresses: A list (or iterable) of addresses. 27 | """ 28 | def inner(f): 29 | self.route_map.add_rule(f, slave_ids, function_codes, addresses) 30 | return f 31 | 32 | return inner 33 | 34 | 35 | class AbstractRequestHandler(BaseRequestHandler): 36 | """ A subclass of :class:`socketserver.BaseRequestHandler` dispatching 37 | incoming Modbus requests using the server's :attr:`route_map`. 38 | 39 | """ 40 | def handle(self): 41 | try: 42 | while True: 43 | try: 44 | mbap_header = recv_exactly(self.request.recv, 7) 45 | remaining = self.get_meta_data(mbap_header)['length'] - 1 46 | request_pdu = recv_exactly(self.request.recv, remaining) 47 | except ValueError: 48 | return 49 | 50 | response_adu = self.process(mbap_header + request_pdu) 51 | self.respond(response_adu) 52 | except: 53 | log.exception('Error while handling request') 54 | raise 55 | 56 | def process(self, request_adu): 57 | """ Process request ADU and return response. 58 | 59 | :param request_adu: A bytearray containing the ADU request. 60 | :return: A bytearray containing the response of the ADU request. 61 | """ 62 | meta_data = self.get_meta_data(request_adu) 63 | request_pdu = self.get_request_pdu(request_adu) 64 | 65 | response_pdu = self.execute_route(meta_data, request_pdu) 66 | response_adu = self.create_response_adu(meta_data, response_pdu) 67 | 68 | return response_adu 69 | 70 | def execute_route(self, meta_data, request_pdu): 71 | """ Execute configured route based on requests meta data and request 72 | PDU. 73 | 74 | :param meta_data: A dict with meta data. It must at least contain 75 | key 'unit_id'. 76 | :param request_pdu: A bytearray containing request PDU. 77 | :return: A bytearry containing reponse PDU. 78 | """ 79 | try: 80 | function = create_function_from_request_pdu(request_pdu) 81 | results =\ 82 | function.execute(meta_data['unit_id'], self.server.route_map) 83 | 84 | try: 85 | # ReadFunction's use results of callbacks to build response 86 | # PDU... 87 | return function.create_response_pdu(results) 88 | except TypeError: 89 | # ...other functions don't. 90 | return function.create_response_pdu() 91 | except ModbusError as e: 92 | function_code = get_function_code_from_request_pdu(request_pdu) 93 | return pack_exception_pdu(function_code, e.error_code) 94 | except Exception: 95 | log.exception('Could not handle request') 96 | function_code = get_function_code_from_request_pdu(request_pdu) 97 | 98 | return pack_exception_pdu(function_code, 99 | ServerDeviceFailureError.error_code) 100 | 101 | def respond(self, response_adu): 102 | """ Send response ADU back to client. 103 | 104 | :param response_adu: A bytearray containing the response of an ADU. 105 | """ 106 | log.debug('--> {0} - {1}.'.format(self.client_address[0], 107 | hexlify(response_adu))) 108 | self.request.sendall(response_adu) 109 | -------------------------------------------------------------------------------- /custom_components/solarman/pysolarman/umodbus/server/serial/__init__.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from binascii import hexlify 3 | from types import MethodType 4 | from serial import SerialTimeoutException 5 | 6 | from . import log 7 | from ...route import Map 8 | from ...server import route 9 | from ...functions import create_function_from_request_pdu 10 | from ...exceptions import ModbusError, ServerDeviceFailureError 11 | from ...utils import (get_function_code_from_request_pdu, 12 | pack_exception_pdu) 13 | from ...client.serial.redundancy_check import CRCError 14 | 15 | 16 | def get_server(server_class, serial_port): 17 | """ Return instance of :param:`server_class` with :param:`request_handler` 18 | bound to it. 19 | This method also binds a :func:`route` method to the server instance. 20 | >>> server = get_server(TcpServer, ('localhost', 502), RequestHandler) 21 | >>> server.serve_forever() 22 | :param server_class: (sub)Class of :class:`socketserver.BaseServer`. 23 | :param request_handler_class: (sub)Class of 24 | :class:`umodbus.server.RequestHandler`. 25 | :return: Instance of :param:`server_class`. 26 | """ 27 | s = server_class() 28 | s.serial_port = serial_port 29 | 30 | s.route_map = Map() 31 | s.route = MethodType(route, s) 32 | 33 | return s 34 | 35 | 36 | class AbstractSerialServer(object): 37 | _shutdown_request = False 38 | 39 | def get_meta_data(self, request_adu): 40 | """" Extract MBAP header from request adu and return it. The dict has 41 | 4 keys: transaction_id, protocol_id, length and unit_id. 42 | 43 | :param request_adu: A bytearray containing request ADU. 44 | :return: Dict with meta data of request. 45 | """ 46 | return { 47 | 'unit_id': struct.unpack('>B', request_adu[:1])[0], 48 | } 49 | 50 | def get_request_pdu(self, request_adu): 51 | """ Extract PDU from request ADU and return it. 52 | 53 | :param request_adu: A bytearray containing request ADU. 54 | :return: An bytearray container request PDU. 55 | """ 56 | return request_adu[1:-2] 57 | 58 | def serve_once(self): 59 | """ Listen and handle 1 request. """ 60 | raise NotImplementedError 61 | 62 | def serve_forever(self, poll_interval=0.5): 63 | """ Wait for incomming requests. """ 64 | self.serial_port.timeout = poll_interval 65 | 66 | while not self._shutdown_request: 67 | try: 68 | self.serve_once() 69 | except (CRCError, struct.error) as e: 70 | log.error('Can\'t handle request: {0}'.format(e)) 71 | except (SerialTimeoutException, ValueError): 72 | pass 73 | 74 | def process(self, request_adu): 75 | """ Process request ADU and return response. 76 | 77 | :param request_adu: A bytearray containing the ADU request. 78 | :return: A bytearray containing the response of the ADU request. 79 | """ 80 | meta_data = self.get_meta_data(request_adu) 81 | request_pdu = self.get_request_pdu(request_adu) 82 | 83 | response_pdu = self.execute_route(meta_data, request_pdu) 84 | response_adu = self.create_response_adu(meta_data, response_pdu) 85 | 86 | return response_adu 87 | 88 | def execute_route(self, meta_data, request_pdu): 89 | """ Execute configured route based on requests meta data and request 90 | PDU. 91 | 92 | :param meta_data: A dict with meta data. It must at least contain 93 | key 'unit_id'. 94 | :param request_pdu: A bytearray containing request PDU. 95 | :return: A bytearry containing reponse PDU. 96 | """ 97 | try: 98 | function = create_function_from_request_pdu(request_pdu) 99 | results =\ 100 | function.execute(meta_data['unit_id'], self.route_map) 101 | 102 | try: 103 | # ReadFunction's use results of callbacks to build response 104 | # PDU... 105 | return function.create_response_pdu(results) 106 | except TypeError: 107 | # ...other functions don't. 108 | return function.create_response_pdu() 109 | except ModbusError as e: 110 | function_code = get_function_code_from_request_pdu(request_pdu) 111 | return pack_exception_pdu(function_code, e.error_code) 112 | except Exception as e: 113 | log.exception('Could not handle request: {0}.'.format(e)) 114 | function_code = get_function_code_from_request_pdu(request_pdu) 115 | 116 | return pack_exception_pdu(function_code, 117 | ServerDeviceFailureError.error_code) 118 | 119 | def respond(self, response_adu): 120 | """ Send response ADU back to client. 121 | 122 | :param response_adu: A bytearray containing the response of an ADU. 123 | """ 124 | log.debug('--> {0}'.format(hexlify(response_adu))) 125 | self.serial_port.write(response_adu) 126 | 127 | def shutdown(self): 128 | self._shutdown_request = True 129 | -------------------------------------------------------------------------------- /custom_components/solarman/pysolarman/umodbus/server/serial/rtu.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | import struct 3 | from binascii import hexlify 4 | 5 | from . import log 6 | from ...server.serial import AbstractSerialServer 7 | from ...client.serial.redundancy_check import get_crc, validate_crc 8 | 9 | 10 | def get_char_size(baudrate): 11 | """ Get the size of 1 character in seconds. 12 | 13 | From the implementation guide: 14 | 15 | "The implementation of RTU reception driver may imply the management of 16 | a lot of interruptions due to the t 1.5 and t 3.5 timers. With high 17 | 18 | communication baud rates, this leads to a heavy CPU load. Consequently 19 | these two timers must be strictly respected when the baud rate is equal 20 | or lower than 19200 Bps. For baud rates greater than 19200 Bps, fixed 21 | values for the 2 timers should be used: it is recommended to use a 22 | value of 750us for the inter-character time-out (t 1.5) and a value of 23 | 1.750ms for inter-frame delay (t 3.5)." 24 | """ 25 | if baudrate <= 19200: 26 | # One frame is 11 bits. 27 | return 11 / baudrate 28 | 29 | # 750 us / 1.5 = 500 us or 0.0005 s. 30 | return 0.0005 31 | 32 | 33 | class RTUServer(AbstractSerialServer): 34 | @property 35 | def serial_port(self): 36 | return self._serial_port 37 | 38 | @serial_port.setter 39 | def serial_port(self, serial_port): 40 | """ Set timeouts on serial port based on baudrate to detect frames. """ 41 | char_size = get_char_size(serial_port.baudrate) 42 | 43 | # See docstring of get_char_size() for meaning of constants below. 44 | serial_port.inter_byte_timeout = 1.5 * char_size 45 | serial_port.timeout = 3.5 * char_size 46 | self._serial_port = serial_port 47 | 48 | def serve_once(self): 49 | """ Listen and handle 1 request. """ 50 | # 256 is the maximum size of a Modbus RTU frame. 51 | request_adu = self.serial_port.read(256) 52 | log.debug('<-- {0}'.format(hexlify(request_adu))) 53 | 54 | if len(request_adu) == 0: 55 | raise ValueError 56 | 57 | response_adu = self.process(request_adu) 58 | self.respond(response_adu) 59 | 60 | def process(self, request_adu): 61 | """ Process request ADU and return response. 62 | 63 | :param request_adu: A bytearray containing the ADU request. 64 | :return: A bytearray containing the response of the ADU request. 65 | """ 66 | validate_crc(request_adu) 67 | return super(RTUServer, self).process(request_adu) 68 | 69 | def create_response_adu(self, meta_data, response_pdu): 70 | """ Build response ADU from meta data and response PDU and return it. 71 | 72 | :param meta_data: A dict with meta data. 73 | :param request_pdu: A bytearray containing request PDU. 74 | :return: A bytearray containing request ADU. 75 | """ 76 | first_part_adu = struct.pack('>B', meta_data['unit_id']) + response_pdu 77 | return first_part_adu + get_crc(first_part_adu) 78 | -------------------------------------------------------------------------------- /custom_components/solarman/pysolarman/umodbus/server/tcp.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from types import MethodType 3 | 4 | from ..route import Map 5 | from ..server import AbstractRequestHandler, route 6 | from ..utils import unpack_mbap, pack_mbap 7 | from ..exceptions import ServerDeviceFailureError 8 | 9 | 10 | def get_server(server_class, server_address, request_handler_class): 11 | """ Return instance of :param:`server_class` with :param:`request_handler` 12 | bound to it. 13 | This method also binds a :func:`route` method to the server instance. 14 | >>> server = get_server(TcpServer, ('localhost', 502), RequestHandler) 15 | >>> server.serve_forever() 16 | :param server_class: (sub)Class of :class:`socketserver.BaseServer`. 17 | :param request_handler_class: (sub)Class of 18 | :class:`umodbus.server.RequestHandler`. 19 | :return: Instance of :param:`server_class`. 20 | """ 21 | s = server_class(server_address, request_handler_class) 22 | 23 | s.route_map = Map() 24 | s.route = MethodType(route, s) 25 | 26 | return s 27 | 28 | 29 | class RequestHandler(AbstractRequestHandler): 30 | """ A subclass of :class:`socketserver.BaseRequestHandler` dispatching 31 | incoming Modbus TCP/IP request using the server's :attr:`route_map`. 32 | 33 | """ 34 | def get_meta_data(self, request_adu): 35 | """" Extract MBAP header from request adu and return it. The dict has 36 | 4 keys: transaction_id, protocol_id, length and unit_id. 37 | 38 | :param request_adu: A bytearray containing request ADU. 39 | :return: Dict with meta data of request. 40 | """ 41 | try: 42 | transaction_id, protocol_id, length, unit_id = \ 43 | unpack_mbap(request_adu[:7]) 44 | except struct.error: 45 | raise ServerDeviceFailureError() 46 | 47 | return { 48 | 'transaction_id': transaction_id, 49 | 'protocol_id': protocol_id, 50 | 'length': length, 51 | 'unit_id': unit_id, 52 | } 53 | 54 | def get_request_pdu(self, request_adu): 55 | """ Extract PDU from request ADU and return it. 56 | 57 | :param request_adu: A bytearray containing request ADU. 58 | :return: An bytearray container request PDU. 59 | """ 60 | return request_adu[7:] 61 | 62 | def create_response_adu(self, meta_data, response_pdu): 63 | """ Build response ADU from meta data and response PDU and return it. 64 | 65 | :param meta_data: A dict with meta data. 66 | :param request_pdu: A bytearray containing request PDU. 67 | :return: A bytearray containing request ADU. 68 | """ 69 | response_mbap = pack_mbap( 70 | transaction_id=meta_data['transaction_id'], 71 | protocol_id=meta_data['protocol_id'], 72 | length=len(response_pdu) + 1, 73 | unit_id=meta_data['unit_id'] 74 | ) 75 | 76 | return response_mbap + response_pdu 77 | -------------------------------------------------------------------------------- /custom_components/solarman/pysolarman/umodbus/utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import struct 3 | import logging 4 | from logging import StreamHandler, Formatter 5 | from functools import wraps 6 | 7 | from . import log 8 | 9 | 10 | def log_to_stream(stream=sys.stderr, level=logging.NOTSET, 11 | fmt=logging.BASIC_FORMAT): 12 | """ Add :class:`logging.StreamHandler` to logger which logs to a stream. 13 | 14 | :param stream. Stream to log to, default STDERR. 15 | :param level: Log level, default NOTSET. 16 | :param fmt: String with log format, default is BASIC_FORMAT. 17 | """ 18 | fmt = Formatter(fmt) 19 | handler = StreamHandler() 20 | handler.setFormatter(fmt) 21 | handler.setLevel(level) 22 | 23 | log.addHandler(handler) 24 | 25 | 26 | def unpack_mbap(mbap): 27 | """ Parse MBAP of 7 bytes and return tuple with fields. 28 | 29 | >>> parse_mbap(b'\x00\x08\x00\x00\x00\x06\x01') 30 | (8, 0, 6, 1) 31 | 32 | :param mbap: Array of 7 bytes. 33 | :return: Tuple with 4 values: Transaction identifier, Protocol identifier, 34 | Length and Unit identifier. 35 | """ 36 | # '>' indicates data is big-endian. Modbus uses this alignment. 'H' and 'B' 37 | # are format characters. 'H' is unsigned short of 2 bytes. 'B' is an 38 | # unsigned char of 1 byte. HHHB sums up to 2 + 2 + 2 + 1 = 7 bytes. 39 | 40 | # TODO What it right exception to raise? Error code 04, Server failure, 41 | # seems most appropriate. 42 | return struct.unpack('>HHHB', mbap) 43 | 44 | 45 | def pack_mbap(transaction_id, protocol_id, length, unit_id): 46 | """ Create and return response MBAP. 47 | 48 | :param transaction_id: Transaction id. 49 | :param protocol_id: Protocol id. 50 | :param length: Length of following bytes in ADU. 51 | :param unit_id: Unit id. 52 | :return: Byte array of 7 bytes. 53 | """ 54 | return struct.pack('>HHHB', transaction_id, protocol_id, length, unit_id) 55 | 56 | 57 | def pack_exception_pdu(function_code, error_code): 58 | """ Return exception PDU of 2 bytes. 59 | 60 | "The exception response message has two fields that differentiate it 61 | from a nor mal response: Function Code Field: In a normal response, the 62 | server echoes the function code of the original request in the function 63 | code field of the response. All function codes have a most - 64 | significant bit (MSB) of 0 (their values are all below 80 hexadecimal). 65 | In an exception response, the server sets the MSB of the function code 66 | to 1. This makes the function code value in an exception response 67 | exactly 80 hexadecimal higher than the value would be for a normal 68 | response. 69 | 70 | With the function code's MSB set, the client's application program can 71 | recognize the exception response and can examine the data field for the 72 | exception code. Data Field: In a normal response, the server may 73 | return data or statistics in the data field (any information that was 74 | requested in the request). In an exception response, the server returns 75 | an exception code in the data field. This defines the server condition 76 | that caused the exception." 77 | 78 | -- MODBUS Application Protocol Specification V1.1b3, chapter 7 79 | 80 | ================ =============== 81 | Field Length (bytes) 82 | ================ =============== 83 | Error code 1 84 | Function code 1 85 | ================ =============== 86 | 87 | :param error_code: Error code. 88 | :param function_code: Function code. 89 | :return: PDU of 2 bytes. 90 | """ 91 | return struct.pack('>BB', function_code + 0x80, error_code) 92 | 93 | 94 | def get_function_code_from_request_pdu(pdu): 95 | """ Return function code from request PDU. 96 | 97 | :return pdu: Array with bytes. 98 | :return: Function code. 99 | """ 100 | return struct.unpack('>B', pdu[:1])[0] 101 | 102 | 103 | def memoize(f): 104 | """ Decorator which caches function's return value each it is called. 105 | If called later with same arguments, the cached value is returned. 106 | """ 107 | cache = {} 108 | 109 | @wraps(f) 110 | def inner(arg): 111 | if arg not in cache: 112 | cache[arg] = f(arg) 113 | return cache[arg] 114 | return inner 115 | 116 | 117 | def recv_exactly(recv_fn, size): 118 | """ Use the function to read and return exactly number of bytes desired. 119 | 120 | https://docs.python.org/3/howto/sockets.html#socket-programming-howto for 121 | more information about why this is necessary. 122 | 123 | :param recv_fn: Function that can return up to given bytes 124 | (i.e. socket.recv, file.read) 125 | :param size: Number of bytes to read. 126 | :return: Byte string with length size. 127 | :raises ValueError: Could not receive enough data (usually timeout). 128 | """ 129 | recv_bytes = 0 130 | chunks = [] 131 | while recv_bytes < size: 132 | chunk = recv_fn(size - recv_bytes) 133 | if len(chunk) == 0: # when closed or empty 134 | break 135 | recv_bytes += len(chunk) 136 | chunks.append(chunk) 137 | 138 | response = b''.join(chunks) 139 | 140 | if len(response) != size: 141 | raise ValueError 142 | 143 | return response 144 | -------------------------------------------------------------------------------- /custom_components/solarman/select.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from typing import Any 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.components.select import SelectEntity, SelectEntityDescription 9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 10 | 11 | from .const import * 12 | from .common import * 13 | from .services import * 14 | from .entity import SolarmanConfigEntry, create_entity, SolarmanWritableEntity 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | _PLATFORM = get_current_file_name(__name__) 19 | 20 | async def async_setup_entry(_: HomeAssistant, config_entry: SolarmanConfigEntry, async_add_entities: AddEntitiesCallback) -> bool: 21 | _LOGGER.debug(f"async_setup_entry: {config_entry.options}") 22 | 23 | async_add_entities(create_entity(lambda x: SolarmanSelectEntity(config_entry.runtime_data, x), d) for d in postprocess_descriptions(config_entry.runtime_data, _PLATFORM)) 24 | 25 | return True 26 | 27 | async def async_unload_entry(_: HomeAssistant, config_entry: SolarmanConfigEntry) -> bool: 28 | _LOGGER.debug(f"async_unload_entry: {config_entry.options}") 29 | 30 | return True 31 | 32 | class SolarmanSelectEntity(SolarmanWritableEntity, SelectEntity): 33 | def __init__(self, coordinator, sensor): 34 | SolarmanWritableEntity.__init__(self, coordinator, sensor) 35 | 36 | self.mask = display.get("mask") if (display := sensor.get("display")) else None 37 | 38 | if "lookup" in sensor: 39 | self.dictionary = sensor["lookup"] 40 | 41 | if len(self.registers) > 1: 42 | _LOGGER.warning(f"SolarmanSelectEntity.__init__: {self._attr_name} contains {len(self.registers)} registers!") 43 | 44 | def get_key(self, value: str): 45 | if self.dictionary: 46 | for o in self.dictionary: 47 | if o["value"] == value and (key := from_bit_index(o["bit"]) if "bit" in o else o["key"]) is not None: 48 | return (key if not "mode" in o else (self._attr_value | key)) if not self.mask else (self._attr_value & (0xFFFFFFFF - self.mask) | key) 49 | 50 | return self.options.index(value) 51 | 52 | @property 53 | def current_option(self): 54 | """Return the current option of this select.""" 55 | return self._attr_state if not self.mask else lookup_value(self._attr_value & self.mask, self.dictionary) 56 | 57 | async def async_select_option(self, option: str) -> None: 58 | """Change the selected option.""" 59 | await self.write(self.get_key(option), option) 60 | -------------------------------------------------------------------------------- /custom_components/solarman/services.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | import voluptuous as vol 6 | 7 | from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse 8 | from homeassistant.helpers import config_validation as cv, device_registry as dr 9 | from homeassistant.exceptions import ServiceValidationError 10 | 11 | from .const import * 12 | from .coordinator import Device, Coordinator 13 | from .pysolarman.umodbus.functions import FUNCTION_CODE 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | HEADER_SCHEMA = { 18 | vol.Required(SERVICES_PARAM_DEVICE): vol.All(vol.Coerce(str)), 19 | vol.Required(SERVICES_PARAM_ADDRESS): vol.All(vol.Coerce(int), vol.Range(min = 0, max = 65535)) 20 | } 21 | 22 | DEPRECATION_HEADER_SCHEMA = { 23 | vol.Required(SERVICES_PARAM_DEVICE): vol.All(vol.Coerce(str)), 24 | vol.Required(SERVICES_PARAM_REGISTER): vol.All(vol.Coerce(int), vol.Range(min = 0, max = 65535)) 25 | } 26 | 27 | COUNT_SCHEMA = { 28 | vol.Required(SERVICES_PARAM_COUNT): vol.All(vol.Coerce(int), vol.Range(min = 0, max = 125)) 29 | } 30 | 31 | VALUE_SCHEMA = { 32 | vol.Required(SERVICES_PARAM_VALUE): vol.All(vol.Coerce(int), vol.Range(min = 0, max = 65535)) 33 | } 34 | 35 | VALUES_SCHEMA = { 36 | vol.Required(SERVICES_PARAM_VALUES): vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(min = 0, max = 65535))]) 37 | } 38 | 39 | def async_register(hass: HomeAssistant) -> None: 40 | _LOGGER.debug(f"register") 41 | 42 | def get_device(device_id) -> Device: 43 | for config_entry_id in dr.async_get(hass).async_get(device_id).config_entries: 44 | if (config_entry := hass.config_entries.async_get_entry(config_entry_id)) and config_entry.domain == DOMAIN and config_entry.runtime_data is not None and isinstance(config_entry.runtime_data, Coordinator): 45 | return config_entry.runtime_data.device 46 | raise ServiceValidationError("No communication interface for device found", translation_domain = DOMAIN, translation_key = "no_interface_found") 47 | 48 | async def read_registers(call: ServiceCall, code: int): 49 | device = get_device(call.data.get(SERVICES_PARAM_DEVICE)) 50 | address = call.data.get(SERVICES_PARAM_ADDRESS) 51 | count = call.data.get(SERVICES_PARAM_COUNT) 52 | 53 | try: 54 | if (response := await device.execute(code, address, count = count)) is not None: 55 | for i in range(0, count): 56 | yield address + i, response[i] 57 | except Exception as e: 58 | raise ServiceValidationError(e, translation_domain = DOMAIN, translation_key = "call_failed") 59 | 60 | async def read_holding_registers(call: ServiceCall): 61 | _LOGGER.debug(f"read_holding_registers: {call}") 62 | 63 | return {k: v async for k, v in read_registers(call, FUNCTION_CODE.READ_HOLDING_REGISTERS)} 64 | 65 | async def read_input_registers(call: ServiceCall): 66 | _LOGGER.debug(f"read_input_registers: {call}") 67 | 68 | return {k: v async for k, v in read_registers(call, FUNCTION_CODE.READ_INPUT_REGISTERS)} 69 | 70 | async def write_single_register(call: ServiceCall) -> None: 71 | _LOGGER.debug(f"write_single_register: {call}") 72 | 73 | device = get_device(call.data.get(SERVICES_PARAM_DEVICE)) 74 | 75 | try: 76 | await device.execute(FUNCTION_CODE.WRITE_SINGLE_REGISTER, call.data.get(SERVICES_PARAM_ADDRESS, call.data.get(SERVICES_PARAM_REGISTER)), data = call.data.get(SERVICES_PARAM_VALUE)) 77 | except Exception as e: 78 | raise ServiceValidationError(e, translation_domain = DOMAIN, translation_key = "call_failed") 79 | 80 | async def write_multiple_registers(call: ServiceCall) -> None: 81 | _LOGGER.debug(f"write_multiple_registers: {call}") 82 | 83 | device = get_device(call.data.get(SERVICES_PARAM_DEVICE)) 84 | 85 | try: 86 | await device.execute(FUNCTION_CODE.WRITE_MULTIPLE_REGISTERS, call.data.get(SERVICES_PARAM_ADDRESS, call.data.get(SERVICES_PARAM_REGISTER)), data = call.data.get(SERVICES_PARAM_VALUES)) 87 | except Exception as e: 88 | raise ServiceValidationError(e, translation_domain = DOMAIN, translation_key = "call_failed") 89 | 90 | hass.services.async_register( 91 | DOMAIN, SERVICE_READ_HOLDING_REGISTERS, read_holding_registers, schema = vol.Schema(HEADER_SCHEMA | COUNT_SCHEMA), supports_response = SupportsResponse.OPTIONAL 92 | ) 93 | 94 | hass.services.async_register( 95 | DOMAIN, SERVICE_READ_INPUT_REGISTERS, read_input_registers, schema = vol.Schema(HEADER_SCHEMA | COUNT_SCHEMA), supports_response = SupportsResponse.OPTIONAL 96 | ) 97 | 98 | hass.services.async_register( 99 | DOMAIN, SERVICE_WRITE_SINGLE_REGISTER, write_single_register, schema = vol.Schema(HEADER_SCHEMA | VALUE_SCHEMA) 100 | ) 101 | 102 | hass.services.async_register( 103 | DOMAIN, SERVICE_WRITE_MULTIPLE_REGISTERS, write_multiple_registers, schema = vol.Schema(HEADER_SCHEMA | VALUES_SCHEMA) 104 | ) 105 | 106 | hass.services.async_register( 107 | DOMAIN, DEPRECATION_SERVICE_WRITE_SINGLE_REGISTER, write_single_register, schema = vol.Schema(DEPRECATION_HEADER_SCHEMA | VALUE_SCHEMA) 108 | ) 109 | 110 | hass.services.async_register( 111 | DOMAIN, DEPRECATION_SERVICE_WRITE_MULTIPLE_REGISTERS, write_multiple_registers, schema = vol.Schema(DEPRECATION_HEADER_SCHEMA | VALUES_SCHEMA) 112 | ) 113 | -------------------------------------------------------------------------------- /custom_components/solarman/services.yaml: -------------------------------------------------------------------------------- 1 | read_holding_registers: 2 | name: Read Holding Registers (Modbus Function Code 3) 3 | description: Read values from consecutive registers at once. (Defaults to reading a single register) 4 | fields: 5 | device: 6 | name: Device 7 | description: Device to read data from 8 | example: "Inverter" 9 | required: true 10 | selector: 11 | device: 12 | filter: 13 | - integration: solarman 14 | address: 15 | name: Address 16 | description: Modbus register address 17 | example: 16384 18 | required: true 19 | selector: 20 | number: 21 | min: 0 22 | max: 65535 23 | mode: box 24 | count: 25 | name: Count 26 | description: Number of registers to read 27 | default: 1 28 | required: true 29 | selector: 30 | number: 31 | min: 1 32 | max: 125 33 | mode: box 34 | 35 | read_input_registers: 36 | name: Read Input Registers (Modbus Function Code 4) 37 | description: Read values from consecutive registers at once. (Defaults to reading a single register) 38 | fields: 39 | device: 40 | name: Device 41 | description: Device to read data from 42 | example: "Inverter" 43 | required: true 44 | selector: 45 | device: 46 | filter: 47 | - integration: solarman 48 | address: 49 | name: Address 50 | description: Modbus register address 51 | example: 16384 52 | required: true 53 | selector: 54 | number: 55 | min: 0 56 | max: 65535 57 | mode: box 58 | count: 59 | name: Count 60 | description: Number of registers to read 61 | default: 1 62 | required: true 63 | selector: 64 | number: 65 | min: 1 66 | max: 125 67 | mode: box 68 | 69 | write_single_register: 70 | name: Write Single Register (Modbus Function Code 6) 71 | description: USE WITH CARE! (Some devices might not accept Code 6 in this case try to use 'Write Multiple Registers') 72 | fields: 73 | device: 74 | name: Device 75 | description: Device to write data to 76 | example: "Inverter" 77 | required: true 78 | selector: 79 | device: 80 | filter: 81 | - integration: solarman 82 | address: 83 | name: Address 84 | description: Modbus register address 85 | example: 16384 86 | required: true 87 | selector: 88 | number: 89 | min: 0 90 | max: 65535 91 | mode: box 92 | value: 93 | name: Value 94 | description: Value to write 95 | example: "1" 96 | required: true 97 | selector: 98 | number: 99 | min: 0 100 | max: 65535 101 | mode: box 102 | 103 | write_multiple_registers: 104 | name: Write Multiple Registers (Modbus Function Code 16) 105 | description: USE WITH CARE! (Some devices might not accept Code 16 in this case try to use 'Write Single Register') 106 | fields: 107 | device: 108 | name: Device 109 | description: Device to write data to 110 | example: "Inverter" 111 | required: true 112 | selector: 113 | device: 114 | filter: 115 | - integration: solarman 116 | address: 117 | name: Address 118 | description: Modbus register address 119 | example: 16384 120 | required: true 121 | selector: 122 | number: 123 | min: 0 124 | max: 65535 125 | mode: box 126 | values: 127 | name: Values 128 | description: Values to write 129 | example: | 130 | - 1 131 | - 2 132 | - 3 133 | required: true 134 | selector: 135 | number: 136 | min: 0 137 | max: 65535 138 | mode: box 139 | 140 | write_holding_register: 141 | name: DEPRECATED! Write Holding Register (Modbus Function Code 6) 142 | description: USE WITH CARE! (Some devices might not accept Code 6 in this case try to use 'Write Multiple Holding Registers') 143 | fields: 144 | device: 145 | name: Device 146 | description: Device to write data to 147 | example: "Inverter" 148 | required: true 149 | selector: 150 | device: 151 | filter: 152 | - integration: solarman 153 | register: 154 | name: Address 155 | description: Modbus register address 156 | example: 16384 157 | required: true 158 | selector: 159 | number: 160 | min: 0 161 | max: 65535 162 | mode: box 163 | value: 164 | name: Value 165 | description: Value to write 166 | example: "1" 167 | required: true 168 | selector: 169 | number: 170 | min: 0 171 | max: 65535 172 | mode: box 173 | 174 | write_multiple_holding_registers: 175 | name: DEPRECATED! Write Multiple Holding Registers (Modbus Function Code 16) 176 | description: USE WITH CARE! (Some devices might not accept Code 16 in this case try to use 'Write Holding Register') 177 | fields: 178 | device: 179 | name: Device 180 | description: Device to write data to 181 | example: "Inverter" 182 | required: true 183 | selector: 184 | device: 185 | filter: 186 | - integration: solarman 187 | register: 188 | name: Address 189 | description: Modbus register address 190 | example: 16384 191 | required: true 192 | selector: 193 | number: 194 | min: 0 195 | max: 65535 196 | mode: box 197 | values: 198 | name: Values 199 | description: Values to write 200 | example: | 201 | - 1 202 | - 2 203 | - 3 204 | required: true 205 | selector: 206 | number: 207 | min: 0 208 | max: 65535 209 | mode: box 210 | -------------------------------------------------------------------------------- /custom_components/solarman/switch.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from typing import Any 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.const import STATE_OFF, STATE_ON 9 | from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass, SwitchEntityDescription 10 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 11 | 12 | from .const import * 13 | from .common import * 14 | from .services import * 15 | from .entity import SolarmanConfigEntry, create_entity, SolarmanWritableEntity 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | _PLATFORM = get_current_file_name(__name__) 20 | 21 | async def async_setup_entry(_: HomeAssistant, config_entry: SolarmanConfigEntry, async_add_entities: AddEntitiesCallback) -> bool: 22 | _LOGGER.debug(f"async_setup_entry: {config_entry.options}") 23 | 24 | async_add_entities(create_entity(lambda x: SolarmanSwitchEntity(config_entry.runtime_data, x), d) for d in postprocess_descriptions(config_entry.runtime_data, _PLATFORM)) 25 | 26 | return True 27 | 28 | async def async_unload_entry(_: HomeAssistant, config_entry: SolarmanConfigEntry) -> bool: 29 | _LOGGER.debug(f"async_unload_entry: {config_entry.options}") 30 | 31 | return True 32 | 33 | class SolarmanSwitchEntity(SolarmanWritableEntity, SwitchEntity): 34 | def __init__(self, coordinator, sensor): 35 | SolarmanWritableEntity.__init__(self, coordinator, sensor) 36 | self._attr_device_class = SwitchDeviceClass.SWITCH 37 | 38 | self._value_on = 1 39 | self._value_off = 0 40 | self._value_bit = None 41 | if "value" in sensor and (value := sensor["value"]) and not isinstance(value, int): 42 | if True in value: 43 | self._value_on = value[True] 44 | if "on" in value: 45 | self._value_on = value["on"] 46 | if False in value: 47 | self._value_off = value[False] 48 | if "off" in value: 49 | self._value_off = value["off"] 50 | if "bit" in value: 51 | self._value_bit = value["bit"] 52 | 53 | def _to_native_value(self, value: int) -> int: 54 | if self._value_bit is not None: 55 | return (self._get_attr_native_value & ~(1 << self._value_bit)) | (value << self._value_bit) 56 | return value 57 | 58 | @property 59 | def is_on(self) -> bool | None: 60 | """Return True if entity is on.""" 61 | return ( 62 | self._attr_native_value >> self._value_bit & 1 63 | if self._attr_native_value is not None and self._value_bit is not None 64 | else self._attr_native_value 65 | ) != self._value_off 66 | 67 | async def async_turn_on(self, **kwargs: Any) -> None: 68 | """Turn the entity on.""" 69 | value = self._to_native_value(self._value_on) 70 | await self.write(value, value) 71 | 72 | async def async_turn_off(self, **kwargs: Any) -> None: 73 | """Turn the entity off.""" 74 | value = self._to_native_value(self._value_off) 75 | await self.write(value, value) 76 | -------------------------------------------------------------------------------- /custom_components/solarman/time.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from datetime import datetime, time 6 | 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.components.time import TimeEntity, TimeEntityDescription 9 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 10 | 11 | from .const import * 12 | from .common import * 13 | from .services import * 14 | from .entity import SolarmanConfigEntry, create_entity, SolarmanWritableEntity 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | _PLATFORM = get_current_file_name(__name__) 19 | 20 | async def async_setup_entry(_: HomeAssistant, config_entry: SolarmanConfigEntry, async_add_entities: AddEntitiesCallback) -> bool: 21 | _LOGGER.debug(f"async_setup_entry: {config_entry.options}") 22 | 23 | async_add_entities(create_entity(lambda x: SolarmanTimeEntity(config_entry.runtime_data, x), d) for d in postprocess_descriptions(config_entry.runtime_data, _PLATFORM)) 24 | 25 | return True 26 | 27 | async def async_unload_entry(_: HomeAssistant, config_entry: SolarmanConfigEntry) -> bool: 28 | _LOGGER.debug(f"async_unload_entry: {config_entry.options}") 29 | 30 | return True 31 | 32 | class SolarmanTimeEntity(SolarmanWritableEntity, TimeEntity): 33 | def __init__(self, coordinator, sensor): 34 | SolarmanWritableEntity.__init__(self, coordinator, sensor) 35 | 36 | self._multiple_registers = len(self.registers) > 1 and self.registers[1] == self.registers[0] + 1 37 | self._hex = "hex" in sensor 38 | self._d = (100 if not "dec" in sensor else sensor["dec"]) if not self._hex else (0x100 if sensor["hex"] is None else sensor["hex"]) 39 | self._offset = sensor["offset"] if "offset" in sensor else None 40 | 41 | def _to_native_value(self, value: time) -> int | list: 42 | if self._hex: 43 | if self._multiple_registers and self._offset and self._offset >= 0x100: 44 | return [concat_hex(div_mod(value.hour, 10)) + self._offset, concat_hex(div_mod(value.minute, 10)) + self._offset] 45 | return concat_hex((value.hour, value.minute)) 46 | return value.hour * self._d + value.minute if not self._multiple_registers else [value.hour, value.minute] 47 | 48 | @property 49 | def native_value(self) -> time | None: 50 | """Return the state of the setting entity.""" 51 | try: 52 | if self._attr_native_value: 53 | if isinstance(self._attr_native_value, list) and len(self._attr_native_value) > 1: 54 | return datetime.strptime(f"{self._attr_native_value[0]}:{self._attr_native_value[1]}", TIME_FORMAT).time() 55 | return datetime.strptime(self._attr_native_value, TIME_FORMAT).time() 56 | except Exception as e: 57 | _LOGGER.debug(f"SolarmanTimeEntity.native_value of {self._attr_name}: {e!r}") 58 | return None 59 | 60 | async def async_set_value(self, value: time) -> None: 61 | """Change the time.""" 62 | await self.write(self._to_native_value(value), value.strftime(TIME_FORMAT)) 63 | -------------------------------------------------------------------------------- /custom_components/solarman/translations/ca.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "El dispositiu ja s'ha configurat" 5 | }, 6 | "error": { 7 | "cannot_connect": "No s'ha pogut connectar", 8 | "invalid_host": "El nom de l'amfitrió o l'adreça IP és invàlida", 9 | "unknown": "Error inesperat" 10 | }, 11 | "step": { 12 | "user": { 13 | "title": "Configuració de l'inversor", 14 | "data": { 15 | "name": "Nom del dispositiu", 16 | "host": "Amfitrió o adreça IP", 17 | "port": "Port", 18 | "transport": "Transport protocol", 19 | "lookup_file": "Perfil" 20 | }, 21 | "data_description": { 22 | "name": "Aquest nom s'utilitzarà com a prefixe de tots els dispositius", 23 | "host": "El nom de l'amfitrió o l'adreça IP del dispositiu, per a connectar-s'hi", 24 | "port": "El port (8899) del dispositiu, per a connectar-s'hi", 25 | "lookup_file": "El fitxer yaml que conté les definicions dels paràmetres de l'inversor" 26 | }, 27 | "sections": { 28 | "additional_options": { 29 | "name": "Opcions addicionals", 30 | "data": { 31 | "mod": "Modificador", 32 | "mppt": "Nombre de MPPTs", 33 | "phase": "Nombre de fases", 34 | "pack": "Nombre de paquets de bateries", 35 | "battery_nominal_voltage": "Voltatge nominal de la bateria de ió-liti", 36 | "battery_life_cycle_rating": "Estimació del cicle de vida esperat de la bateria d'ió-liti", 37 | "mb_slave_id": "ID de l'esclau Modbus (normalment 1)" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | }, 44 | "options": { 45 | "error": { 46 | "cannot_connect": "No s'ha pogut connectar", 47 | "invalid_host": "El nom de l'amfitrió o l'adreça IP és invàlida", 48 | "unknown": "Error inesperat" 49 | }, 50 | "step": { 51 | "init": { 52 | "title": "Configuració de l'inversor", 53 | "data": { 54 | "host": "Amfitrió o adreça IP", 55 | "port": "Port", 56 | "transport": "Transport protocol", 57 | "lookup_file": "Perfil" 58 | }, 59 | "data_description": { 60 | "host": "El nom de l'amfitrió o l'adreça IP del dispositiu, per a connectar-s'hi", 61 | "port": "El port (8899) del dispositiu, per a connectar-s'hi", 62 | "lookup_file": "El fitxer yaml que conté les definicions dels paràmetres de l'inversor" 63 | }, 64 | "sections": { 65 | "additional_options": { 66 | "name": "Opcions addicionals", 67 | "data": { 68 | "mod": "Modificador", 69 | "mppt": "Nombre de MPPTs", 70 | "phase": "Nombre de fases", 71 | "pack": "Nombre de paquets de bateries", 72 | "battery_nominal_voltage": "Voltatge nominal de la bateria de ió-liti", 73 | "battery_life_cycle_rating": "Estimació del cicle de vida esperat de la bateria d'ió-liti", 74 | "mb_slave_id": "ID de l'esclau Modbus (normalment 1)" 75 | } 76 | } 77 | } 78 | } 79 | } 80 | }, 81 | "selector": { 82 | "transport": { 83 | "options": { 84 | "tcp": "TCP", 85 | "udp": "UDP", 86 | "modbus_tcp": "Modbus TCP" 87 | } 88 | } 89 | }, 90 | "entity": { 91 | "sensor": { 92 | "today_battery_charge": { 93 | "name": "Càrrega de la bateria d'avui" 94 | }, 95 | "today_battery_discharge": { 96 | "name": "Descàrrega de la bateria d'avui" 97 | }, 98 | "today_battery_life_cycles": { 99 | "name": "Cicles de càrrega de la bateria d'avui" 100 | }, 101 | "today_energy_export": { 102 | "name": "Exportació d'energia d'avui" 103 | }, 104 | "today_energy_import": { 105 | "name": "Importació d'energia d'avui" 106 | }, 107 | "today_generation": { 108 | "name": "Generació d'avui" 109 | }, 110 | "today_load_consumption": { 111 | "name": "Consum de càrrega d'avui" 112 | }, 113 | "today_losses": { 114 | "name": "Pèrdues d'avui" 115 | }, 116 | "today_production": { 117 | "name": "Producció d'avui" 118 | }, 119 | "total_battery_charge": { 120 | "name": "Càrrega total de la bateria" 121 | }, 122 | "total_battery_discharge": { 123 | "name": "Descàrrega total de la bateria" 124 | }, 125 | "total_battery_life_cycles": { 126 | "name": "Cicles totals de la bateria" 127 | }, 128 | "total_energy_export": { 129 | "name": "Exportació total d'energia" 130 | }, 131 | "total_energy_import": { 132 | "name": "Importació total d'energia" 133 | }, 134 | "total_generation": { 135 | "name": "Generació total" 136 | }, 137 | "total_load_consumption": { 138 | "name": "Consum total de càrrega" 139 | }, 140 | "total_losses": { 141 | "name": "Pèrdues totals" 142 | }, 143 | "total_production": { 144 | "name": "Producció total" 145 | } 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /custom_components/solarman/translations/cs.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Zařízení je již nakonfigurováno" 5 | }, 6 | "error": { 7 | "cannot_connect": "Nepodařilo se připojit", 8 | "invalid_host": "Neplatný název hostitele nebo IP adresa", 9 | "unknown": "Neočekávaná chyba" 10 | }, 11 | "step": { 12 | "user": { 13 | "title": "Konfigurace měniče", 14 | "data": { 15 | "name": "Název", 16 | "host": "Hostitel nebo IP adresa", 17 | "port": "Port", 18 | "transport": "Transportní protokol", 19 | "lookup_file": "Profil" 20 | }, 21 | "data_description": { 22 | "name": "Tento název bude mít předponu u všech senzorů (změňte, jak chcete)", 23 | "host": "Název hostitele nebo IP adresa zařízení, ke kterému se chcete připojit", 24 | "port": "Port (8899) zařízení, ke kterému se chcete připojit", 25 | "lookup_file": "Soubor yaml obsahující definice parametrů měniče" 26 | }, 27 | "sections": { 28 | "additional_options": { 29 | "name": "Další možnosti", 30 | "data": { 31 | "mod": "Modifikátor", 32 | "mppt": "Počet MPPT", 33 | "phase": "Počet fází", 34 | "pack": "Počet bateriových sad", 35 | "battery_nominal_voltage": "Jmenovité napětí lithium-iontové baterie", 36 | "battery_life_cycle_rating": "Předpokládaná životnost lithium-iontové baterie", 37 | "mb_slave_id": "Modbus Slave ID (obvykle 1)" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | }, 44 | "options": { 45 | "error": { 46 | "cannot_connect": "Nepodařilo se připojit", 47 | "invalid_host": "Neplatný název hostitele nebo IP adresa", 48 | "unknown": "Neočekávaná chyba" 49 | }, 50 | "step": { 51 | "init": { 52 | "title": "Konfigurace měniče", 53 | "data": { 54 | "host": "Hostitel nebo IP adresa", 55 | "port": "Port", 56 | "transport": "Transportní protokol", 57 | "lookup_file": "Profil" 58 | }, 59 | "data_description": { 60 | "host": "Název hostitele nebo IP adresa zařízení, ke kterému se chcete připojit", 61 | "port": "Port (8899) zařízení, ke kterému se chcete připojit", 62 | "lookup_file": "Soubor yaml obsahující definice parametrů měniče" 63 | }, 64 | "sections": { 65 | "additional_options": { 66 | "name": "Další možnosti", 67 | "data": { 68 | "mod": "Modifikátor", 69 | "mppt": "Počet MPPT", 70 | "phase": "Počet fází", 71 | "pack": "Počet bateriových sad", 72 | "battery_nominal_voltage": "Jmenovité napětí lithium-iontové baterie", 73 | "battery_life_cycle_rating": "Předpokládaná životnost lithium-iontové baterie", 74 | "mb_slave_id": "Modbus Slave ID (obvykle 1)" 75 | } 76 | } 77 | } 78 | } 79 | } 80 | }, 81 | "selector": { 82 | "transport": { 83 | "options": { 84 | "tcp": "TCP", 85 | "udp": "UDP", 86 | "modbus_tcp": "Modbus TCP" 87 | } 88 | } 89 | }, 90 | "entity": { 91 | "sensor": { 92 | "today_battery_charge": { 93 | "name": "Dnešní nabití baterie" 94 | }, 95 | "today_battery_discharge": { 96 | "name": "Dnešní vybití baterie" 97 | }, 98 | "today_battery_life_cycles": { 99 | "name": "Dnešní počet cyklů baterie" 100 | }, 101 | "today_energy_export": { 102 | "name": "Dnešní export energie" 103 | }, 104 | "today_energy_import": { 105 | "name": "Dnešní import energie" 106 | }, 107 | "today_generation": { 108 | "name": "Dnešní generace" 109 | }, 110 | "today_load_consumption": { 111 | "name": "Dnešní spotřeba zátěže" 112 | }, 113 | "today_losses": { 114 | "name": "Dnešní ztráty" 115 | }, 116 | "today_production": { 117 | "name": "Dnešní produkce" 118 | }, 119 | "total_battery_charge": { 120 | "name": "Celkové nabití baterie" 121 | }, 122 | "total_battery_discharge": { 123 | "name": "Celkové vybití baterie" 124 | }, 125 | "total_battery_life_cycles": { 126 | "name": "Celkový počet cyklů baterie" 127 | }, 128 | "total_energy_export": { 129 | "name": "Celkový export energie" 130 | }, 131 | "total_energy_import": { 132 | "name": "Celkový import energie" 133 | }, 134 | "total_generation": { 135 | "name": "Celková generace" 136 | }, 137 | "total_load_consumption": { 138 | "name": "Celková spotřeba zátěže" 139 | }, 140 | "total_losses": { 141 | "name": "Celkové ztráty" 142 | }, 143 | "total_production": { 144 | "name": "Celková produkce" 145 | } 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /custom_components/solarman/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Gerät ist bereits konfiguriert" 5 | }, 6 | "error": { 7 | "cannot_connect": "Verbindung fehlgeschlagen", 8 | "invalid_host": "Ungültiger Hostname oder ungültige IP-Adresse", 9 | "unknown": "Unerwarteter Fehler" 10 | }, 11 | "step": { 12 | "user": { 13 | "title": "Konfiguration der Verbindung zum Wechselrichter", 14 | "data": { 15 | "name": "Gerätename", 16 | "host": "Host (IP-Adresse oder Hostname)", 17 | "port": "Port (normalerweise 8899)", 18 | "transport": "Transport protocol", 19 | "lookup_file": "Profil" 20 | }, 21 | "data_description": { 22 | "name": "Prefix für alle Parameterwerte (nach Belieben zu ändern)", 23 | "host": "Der Hostname oder die IP-Adresse des Geräts, mit dem eine Verbindung hergestellt werden soll", 24 | "port": "Der Port (8899) des Geräts, mit dem eine Verbindung hergestellt werden soll", 25 | "lookup_file": "YAML-Datei mit der Parameter-Definition" 26 | }, 27 | "sections": { 28 | "additional_options": { 29 | "name": "Zusätzliche Optionen", 30 | "data": { 31 | "mod": "Modifikator", 32 | "mppt": "Anzahl der MPPTs", 33 | "phase": "Anzahl der Phasen", 34 | "pack": "Anzahl Akkupacks", 35 | "battery_nominal_voltage": "Nennspannung des Lithium-Ionen-Akkus", 36 | "battery_life_cycle_rating": "Erwartete Lebensdauer der Lithium-Ionen-Batterie", 37 | "mb_slave_id": "Modbus-Slave-ID (normalerweise 1)" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | }, 44 | "options": { 45 | "error": { 46 | "cannot_connect": "Verbindung fehlgeschlagen", 47 | "invalid_host": "Ungültiger Hostname oder ungültige IP-Adresse", 48 | "unknown": "Unerwarteter Fehler" 49 | }, 50 | "step": { 51 | "init": { 52 | "title": "Konfiguration der Verbindung zum Wechselrichter", 53 | "data": { 54 | "host": "Host (IP-Adresse oder Hostname)", 55 | "port": "Port (normalerweise 8899)", 56 | "transport": "Transport protocol", 57 | "lookup_file": "Profil" 58 | }, 59 | "data_description": { 60 | "host": "Der Hostname oder die IP-Adresse des Geräts, mit dem eine Verbindung hergestellt werden soll", 61 | "port": "Der Port (8899) des Geräts, mit dem eine Verbindung hergestellt werden soll", 62 | "lookup_file": "YAML-Datei mit der Parameter-Definition" 63 | }, 64 | "sections": { 65 | "additional_options": { 66 | "name": "Zusätzliche Optionen", 67 | "data": { 68 | "mod": "Modifikator", 69 | "mppt": "Anzahl der MPPTs", 70 | "phase": "Anzahl der Phasen", 71 | "pack": "Anzahl Akkupacks", 72 | "battery_nominal_voltage": "Nennspannung des Lithium-Ionen-Akkus", 73 | "battery_life_cycle_rating": "Erwartete Lebensdauer der Lithium-Ionen-Batterie", 74 | "mb_slave_id": "Modbus-Slave-ID (normalerweise 1)" 75 | } 76 | } 77 | } 78 | } 79 | } 80 | }, 81 | "selector": { 82 | "transport": { 83 | "options": { 84 | "tcp": "TCP", 85 | "udp": "UDP", 86 | "modbus_tcp": "Modbus TCP" 87 | } 88 | } 89 | }, 90 | "entity": { 91 | "sensor": { 92 | "today_battery_charge": { 93 | "name": "Heutige Batterieladung" 94 | }, 95 | "today_battery_discharge": { 96 | "name": "Heutige Batterieentladung" 97 | }, 98 | "today_battery_life_cycles": { 99 | "name": "Heutige Batterielebenszyklen" 100 | }, 101 | "today_energy_export": { 102 | "name": "Heutige Energieexport" 103 | }, 104 | "today_energy_import": { 105 | "name": "Heutige Energieimport" 106 | }, 107 | "today_generation": { 108 | "name": "Heutige Generation" 109 | }, 110 | "today_load_consumption": { 111 | "name": "Heutiger Lastverbrauch" 112 | }, 113 | "today_losses": { 114 | "name": "Heutigen Verluste" 115 | }, 116 | "today_production": { 117 | "name": "Heutige Produktion" 118 | }, 119 | "total_battery_charge": { 120 | "name": "Gesamt Batterieladung" 121 | }, 122 | "total_battery_discharge": { 123 | "name": "Gesamt Batterieentladung" 124 | }, 125 | "total_battery_life_cycles": { 126 | "name": "Gesamt Batterielebenszyklen" 127 | }, 128 | "total_energy_export": { 129 | "name": "Gesamt Energieexport" 130 | }, 131 | "total_energy_import": { 132 | "name": "Gesamt Energieimport" 133 | }, 134 | "total_generation": { 135 | "name": "Gesamt Generation" 136 | }, 137 | "total_load_consumption": { 138 | "name": "Gesamt Lastverbrauch" 139 | }, 140 | "total_losses": { 141 | "name": "Gesamt Verluste" 142 | }, 143 | "total_production": { 144 | "name": "Gesamt Produktion" 145 | } 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /custom_components/solarman/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured" 5 | }, 6 | "error": { 7 | "cannot_connect": "Failed to connect", 8 | "invalid_host": "Invalid hostname or IP address", 9 | "unknown": "Unexpected error" 10 | }, 11 | "step": { 12 | "user": { 13 | "title": "Inverter configuration", 14 | "data": { 15 | "name": "Device name", 16 | "host": "Hostname or IP address", 17 | "port": "Port", 18 | "transport": "Transport protocol", 19 | "lookup_file": "Profile" 20 | }, 21 | "data_description": { 22 | "name": "This name will be prefixed to all sensors (change as you like)", 23 | "host": "Hostname or IP address of the device to connect to", 24 | "port": "Port (8899) of the device to connect to", 25 | "transport": "Transport protocol used during communication with the device", 26 | "lookup_file": "The yaml file containing inverter parameter definitions" 27 | }, 28 | "sections": { 29 | "additional_options": { 30 | "name": "Additional options", 31 | "data": { 32 | "mod": "Modifier", 33 | "mppt": "Number of MPPTs", 34 | "phase": "Number of Phases", 35 | "pack": "Number of Battery packs", 36 | "battery_nominal_voltage": "Lithium-ion battery nominal voltage", 37 | "battery_life_cycle_rating": "Lithium-ion battery expected life cycle rating", 38 | "mb_slave_id": "Modbus Slave ID (usually 1)" 39 | } 40 | } 41 | } 42 | } 43 | } 44 | }, 45 | "options": { 46 | "error": { 47 | "cannot_connect": "Failed to connect", 48 | "invalid_host": "Invalid hostname or IP address", 49 | "unknown": "Unexpected error" 50 | }, 51 | "step": { 52 | "init": { 53 | "title": "Inverter configuration", 54 | "data": { 55 | "host": "Hostname or IP address", 56 | "port": "Port", 57 | "transport": "Transport protocol", 58 | "lookup_file": "Profile" 59 | }, 60 | "data_description": { 61 | "host": "Hostname or IP address of the device to connect to", 62 | "port": "Port (8899) of the device to connect to", 63 | "transport": "Transport protocol used during communication with the device", 64 | "lookup_file": "The yaml file containing inverter parameter definitions" 65 | }, 66 | "sections": { 67 | "additional_options": { 68 | "name": "Additional options", 69 | "data": { 70 | "mod": "Modifier", 71 | "mppt": "Number of MPPTs", 72 | "phase": "Number of Phases", 73 | "pack": "Number of Battery packs", 74 | "battery_nominal_voltage": "Lithium-ion battery nominal voltage", 75 | "battery_life_cycle_rating": "Lithium-ion battery expected life cycle rating", 76 | "mb_slave_id": "Modbus Slave ID (usually 1)" 77 | } 78 | } 79 | } 80 | } 81 | } 82 | }, 83 | "selector": { 84 | "transport": { 85 | "options": { 86 | "tcp": "TCP", 87 | "udp": "UDP", 88 | "modbus_tcp": "Modbus TCP" 89 | } 90 | } 91 | }, 92 | "entity": { 93 | "sensor": { 94 | "today_battery_charge": { 95 | "name": "Today's Battery Charge" 96 | }, 97 | "today_battery_discharge": { 98 | "name": "Today's Battery Discharge" 99 | }, 100 | "today_battery_life_cycles": { 101 | "name": "Today's Battery Life Cycles" 102 | }, 103 | "today_energy_export": { 104 | "name": "Today's Energy Export" 105 | }, 106 | "today_energy_import": { 107 | "name": "Today's Energy Import" 108 | }, 109 | "today_generation": { 110 | "name": "Today's Generation" 111 | }, 112 | "today_load_consumption": { 113 | "name": "Today's Load Consumption" 114 | }, 115 | "today_losses": { 116 | "name": "Today's Losses" 117 | }, 118 | "today_production": { 119 | "name": "Today's Production" 120 | } 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /custom_components/solarman/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Questo dispositivo è già configurato" 5 | }, 6 | "error": { 7 | "cannot_connect": "Impossibile connettersi", 8 | "invalid_host": "Nome o Indirizzo IP forniti non sono validi", 9 | "unknown": "Errore" 10 | }, 11 | "step": { 12 | "user": { 13 | "title": "Configurazione della connessione inverter", 14 | "data": { 15 | "name": "Nome del dispositivo", 16 | "host": "Inverter (nome di dominio o indirizzo IP)", 17 | "port": "Porta (solitamente 8899)", 18 | "transport": "Transport protocol", 19 | "lookup_file": "Profilo" 20 | }, 21 | "data_description": { 22 | "name": "Questo valore sarà il prefisso di tutte le entità (scegli il nome che più ritieni adatto)", 23 | "host": "Il nome host o l'indirizzo IP del dispositivo a cui connettersi", 24 | "port": "La porta (8899) del dispositivo a cui connettersi", 25 | "lookup_file": "File YAML contenente la definizione Inverter" 26 | }, 27 | "sections": { 28 | "additional_options": { 29 | "name": "Opzioni aggiuntive", 30 | "data": { 31 | "mod": "Modificatore", 32 | "mppt": "Numero di MPPT", 33 | "phase": "Numero di fasi", 34 | "pack": "Numero di pacchi batteria", 35 | "battery_nominal_voltage": "Voltaggio nominale della batteria agli ioni di litio", 36 | "battery_life_cycle_rating": "Ciclo di vita previsto della batteria agli ioni di litio", 37 | "mb_slave_id": "Slave ID di Modbus (solitamente 1)" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | }, 44 | "options": { 45 | "error": { 46 | "cannot_connect": "Impossibile connettersi", 47 | "invalid_host": "Nome o Indirizzo IP forniti non sono validi", 48 | "unknown": "Errore" 49 | }, 50 | "step": { 51 | "init": { 52 | "title": "Configurazione della connessione inverter", 53 | "data": { 54 | "host": "Inverter (nome di dominio o indirizzo IP)", 55 | "port": "Porta (solitamente 8899)", 56 | "transport": "Transport protocol", 57 | "lookup_file": "Profilo" 58 | }, 59 | "data_description": { 60 | "host": "Il nome host o l'indirizzo IP del dispositivo a cui connettersi", 61 | "port": "La porta (8899) del dispositivo a cui connettersi", 62 | "lookup_file": "File YAML contenente la definizione Inverter" 63 | }, 64 | "sections": { 65 | "additional_options": { 66 | "name": "Opzioni aggiuntive", 67 | "data": { 68 | "mod": "Modificatore", 69 | "mppt": "Numero di MPPT", 70 | "phase": "Numero di fasi", 71 | "pack": "Numero di pacchi batteria", 72 | "battery_nominal_voltage": "Voltaggio nominale della batteria agli ioni di litio", 73 | "battery_life_cycle_rating": "Ciclo di vita previsto della batteria agli ioni di litio", 74 | "mb_slave_id": "Slave ID di Modbus (solitamente 1)" 75 | } 76 | } 77 | } 78 | } 79 | } 80 | }, 81 | "selector": { 82 | "transport": { 83 | "options": { 84 | "tcp": "TCP", 85 | "udp": "UDP", 86 | "modbus_tcp": "Modbus TCP" 87 | } 88 | } 89 | }, 90 | "entity": { 91 | "sensor": { 92 | "today_battery_charge": { 93 | "name": "Carica della batteria odierna" 94 | }, 95 | "today_battery_discharge": { 96 | "name": "Scarica della batteria odierna" 97 | }, 98 | "today_battery_life_cycles": { 99 | "name": "Cicli di vita della batteria odierni" 100 | }, 101 | "today_energy_export": { 102 | "name": "Energia esportata odierna" 103 | }, 104 | "today_energy_import": { 105 | "name": "Energia importata odierna" 106 | }, 107 | "today_generation": { 108 | "name": "Generazione odierna" 109 | }, 110 | "today_load_consumption": { 111 | "name": "Consumo del carico odierno" 112 | }, 113 | "today_losses": { 114 | "name": "Perdite odierne" 115 | }, 116 | "today_production": { 117 | "name": "Produzione odierna" 118 | }, 119 | "total_battery_charge": { 120 | "name": "Carica totale della batteria" 121 | }, 122 | "total_battery_discharge": { 123 | "name": "Scarica totale della batteria" 124 | }, 125 | "total_battery_life_cycles": { 126 | "name": "Cicli totali della batteria" 127 | }, 128 | "total_energy_export": { 129 | "name": "Esportazione totale di energia" 130 | }, 131 | "total_energy_import": { 132 | "name": "Importazione totale di energia" 133 | }, 134 | "total_generation": { 135 | "name": "Generazione totale" 136 | }, 137 | "total_load_consumption": { 138 | "name": "Consumo totale del carico" 139 | }, 140 | "total_losses": { 141 | "name": "Perdite totali" 142 | }, 143 | "total_production": { 144 | "name": "Produzione totale" 145 | } 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /custom_components/solarman/translations/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" 5 | }, 6 | "error": { 7 | "cannot_connect": "Nie uda\u0142o si\u0119 po\u0142\u0105czy\u0107", 8 | "invalid_host": "Nieprawid\u0142owa nazwa hosta lub adres IP", 9 | "unknown": "Nieznany b\u0142\u0105d" 10 | }, 11 | "step": { 12 | "user": { 13 | "title": "Konfiguracja po\u0142\u0105czenia falownika", 14 | "data": { 15 | "name": "Nazwa urz\u0105dzenia", 16 | "host": "Host (adres IP lub nazwa hosta)", 17 | "port": "Port (zwykle 8899)", 18 | "transport": "Transport protocol", 19 | "lookup_file": "Profil" 20 | }, 21 | "data_description": { 22 | "name": "Ta nazwa b\u0119dzie poprzedza\u0107 wszystkie warto\u015bci parametr\u00f3w (zmie\u0144 wedle uznania)", 23 | "host": "Nazwa hosta lub adres IP urządzenia, z którym chcesz się połączyć", 24 | "port": "Port (8899) urządzenia, z którym chcesz się połączyć", 25 | "lookup_file": "Plik yaml u\u017cywany do definiowania parametr\u00f3w" 26 | }, 27 | "sections": { 28 | "additional_options": { 29 | "name": "Opcje dodatkowe", 30 | "data": { 31 | "mod": "Modyfikator", 32 | "mppt": "Liczba MPPT", 33 | "phase": "Liczba faz", 34 | "pack": "Liczba pakietów baterii", 35 | "battery_nominal_voltage": "Napi\u0119cie znamionowe akumulatora litowo-jonowego", 36 | "battery_life_cycle_rating": "Oczekiwany wska\u017anik cyklu \u017cycia akumulatora litowo-jonowego", 37 | "mb_slave_id": "Modbus Slave ID (zwykle 1)" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | }, 44 | "options": { 45 | "error": { 46 | "cannot_connect": "Nie uda\u0142o si\u0119 po\u0142\u0105czy\u0107", 47 | "invalid_host": "Nieprawid\u0142owa nazwa hosta lub adres IP", 48 | "unknown": "Nieznany b\u0142\u0105d" 49 | }, 50 | "step": { 51 | "init": { 52 | "title": "Konfiguracja po\u0142\u0105czenia falownika", 53 | "data": { 54 | "host": "Host (adres IP lub nazwa hosta)", 55 | "port": "Port (zwykle 8899)", 56 | "transport": "Transport protocol", 57 | "lookup_file": "Profil" 58 | }, 59 | "data_description": { 60 | "host": "Nazwa hosta lub adres IP urządzenia, z którym chcesz się połączyć", 61 | "port": "Port (8899) urządzenia, z którym chcesz się połączyć", 62 | "lookup_file": "Plik yaml u\u017cywany do definiowania parametr\u00f3w" 63 | }, 64 | "sections": { 65 | "additional_options": { 66 | "name": "Opcje dodatkowe", 67 | "data": { 68 | "mod": "Modyfikator", 69 | "mppt": "Liczba MPPT", 70 | "phase": "Liczba faz", 71 | "pack": "Liczba pakietów baterii", 72 | "battery_nominal_voltage": "Napi\u0119cie znamionowe akumulatora litowo-jonowego", 73 | "battery_life_cycle_rating": "Oczekiwany wska\u017anik cyklu \u017cycia akumulatora litowo-jonowego", 74 | "mb_slave_id": "Modbus Slave ID (zwykle 1)" 75 | } 76 | } 77 | } 78 | } 79 | } 80 | }, 81 | "selector": { 82 | "transport": { 83 | "options": { 84 | "tcp": "TCP", 85 | "udp": "UDP", 86 | "modbus_tcp": "Modbus TCP" 87 | } 88 | } 89 | }, 90 | "entity": { 91 | "sensor": { 92 | "today_battery_charge": { 93 | "name": "Dzisiejsze ładowanie baterii" 94 | }, 95 | "today_battery_discharge": { 96 | "name": "Dzisiejsze rozładowanie baterii" 97 | }, 98 | "today_battery_life_cycles": { 99 | "name": "Dzisiejsze cykle baterii" 100 | }, 101 | "today_energy_export": { 102 | "name": "Dzisiejszy eksport energii" 103 | }, 104 | "today_energy_import": { 105 | "name": "Dzisiejszy import energii" 106 | }, 107 | "today_generation": { 108 | "name": "Dzisiejsza generacja" 109 | }, 110 | "today_load_consumption": { 111 | "name": "Dzisiejsze zużycie prądu" 112 | }, 113 | "today_losses": { 114 | "name": "Dzisiejsze straty" 115 | }, 116 | "today_production": { 117 | "name": "Dzisiejsza produkcja" 118 | }, 119 | "total_battery_charge": { 120 | "name": "Całkowite ładowanie baterii" 121 | }, 122 | "total_battery_discharge": { 123 | "name": "Całkowite rozładowanie baterii" 124 | }, 125 | "total_battery_life_cycles": { 126 | "name": "Całkowita liczba cykli baterii" 127 | }, 128 | "total_energy_export": { 129 | "name": "Całkowity eksport energii" 130 | }, 131 | "total_energy_import": { 132 | "name": "Całkowity import energii" 133 | }, 134 | "total_generation": { 135 | "name": "Całkowita generacja" 136 | }, 137 | "total_load_consumption": { 138 | "name": "Całkowite zużycie prądu" 139 | }, 140 | "total_losses": { 141 | "name": "Całkowite straty" 142 | }, 143 | "total_production": { 144 | "name": "Całkowita produkcja" 145 | } 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /custom_components/solarman/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "O dispositivo já está configurado" 5 | }, 6 | "error": { 7 | "cannot_connect": "Falhou ao conectar", 8 | "invalid_host": "Nome de host ou endereço IP inválido", 9 | "unknown": "Erro inesperado" 10 | }, 11 | "step": { 12 | "user": { 13 | "title": "Configuração de conexão do inversor", 14 | "data": { 15 | "name": "Nome do dispositivo", 16 | "host": "Host (ip ou nome do host)", 17 | "port": "Porta (geralmente 8899)", 18 | "transport": "Transport protocol", 19 | "lookup_file": "Perfil" 20 | }, 21 | "data_description": { 22 | "name": "Este nome será prefixado para todos os valores de parâmetro (altere como quiser)", 23 | "host": "O nome do host ou endereço IP do dispositivo ao qual se conectar", 24 | "port": "A porta (8899) do dispositivo ao qual se conectar", 25 | "lookup_file": "O arquivo yaml a ser usado para definição de parâmetro" 26 | }, 27 | "sections": { 28 | "additional_options": { 29 | "name": "Opções adicionais", 30 | "data": { 31 | "mod": "Modificador", 32 | "mppt": "Número de MPPTs", 33 | "phase": "Número de fases", 34 | "pack": "Número de baterias", 35 | "battery_nominal_voltage": "Tensão nominal da bateria de íons de lítio", 36 | "battery_life_cycle_rating": "Classificação do ciclo de vida esperado da bateria de íons de lítio", 37 | "mb_slave_id": "Modbus Slave ID (geralmente 1)" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | }, 44 | "options": { 45 | "error": { 46 | "cannot_connect": "Falhou ao conectar", 47 | "invalid_host": "Nome de host ou endereço IP inválido", 48 | "unknown": "Erro inesperado" 49 | }, 50 | "step": { 51 | "init": { 52 | "title": "Configuração de conexão do inversor", 53 | "data": { 54 | "host": "Host (ip ou nome do host)", 55 | "port": "Porta (geralmente 8899)", 56 | "transport": "Transport protocol", 57 | "lookup_file": "Perfil" 58 | }, 59 | "data_description": { 60 | "host": "O nome do host ou endereço IP do dispositivo ao qual se conectar", 61 | "port": "A porta (8899) do dispositivo ao qual se conectar", 62 | "lookup_file": "O arquivo yaml a ser usado para definição de parâmetro" 63 | }, 64 | "sections": { 65 | "additional_options": { 66 | "name": "Opções adicionais", 67 | "data": { 68 | "mod": "Modificador", 69 | "mppt": "Número de MPPTs", 70 | "phase": "Número de fases", 71 | "pack": "Número de baterias", 72 | "battery_nominal_voltage": "Tensão nominal da bateria de íons de lítio", 73 | "battery_life_cycle_rating": "Classificação do ciclo de vida esperado da bateria de íons de lítio", 74 | "mb_slave_id": "Modbus Slave ID (geralmente 1)" 75 | } 76 | } 77 | } 78 | } 79 | } 80 | }, 81 | "selector": { 82 | "transport": { 83 | "options": { 84 | "tcp": "TCP", 85 | "udp": "UDP", 86 | "modbus_tcp": "Modbus TCP" 87 | } 88 | } 89 | }, 90 | "entity": { 91 | "sensor": { 92 | "today_battery_charge": { 93 | "name": "Carga da bateria de hoje" 94 | }, 95 | "today_battery_discharge": { 96 | "name": "Descarga da bateria de hoje" 97 | }, 98 | "today_battery_life_cycles": { 99 | "name": "Ciclos de bateria de hoje" 100 | }, 101 | "today_energy_export": { 102 | "name": "Exportação de energia de hoje" 103 | }, 104 | "today_energy_import": { 105 | "name": "Importação de energia de hoje" 106 | }, 107 | "today_generation": { 108 | "name": "A geração de hoje" 109 | }, 110 | "today_load_consumption": { 111 | "name": "Consumo de carga de hoje" 112 | }, 113 | "today_losses": { 114 | "name": "Perdas de hoje" 115 | }, 116 | "today_production": { 117 | "name": "Produção de hoje" 118 | }, 119 | "total_battery_charge": { 120 | "name": "Carga total da bateria" 121 | }, 122 | "total_battery_discharge": { 123 | "name": "Descarga total da bateria" 124 | }, 125 | "total_battery_life_cycles": { 126 | "name": "Ciclos totais da bateria" 127 | }, 128 | "total_energy_export": { 129 | "name": "Exportação total de energia" 130 | }, 131 | "total_energy_import": { 132 | "name": "Importação total de energia" 133 | }, 134 | "total_generation": { 135 | "name": "Geração total" 136 | }, 137 | "total_load_consumption": { 138 | "name": "Consumo total de carga" 139 | }, 140 | "total_losses": { 141 | "name": "Perdas totais" 142 | }, 143 | "total_production": { 144 | "name": "Produção total" 145 | } 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /custom_components/solarman/translations/ua.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Пристрій вже сконфігуровано" 5 | }, 6 | "error": { 7 | "cannot_connect": "Не вдалося підключитися", 8 | "invalid_host": "Не коректний хостнейм або IP-адреса", 9 | "unknown": "Неочікувана помилка" 10 | }, 11 | "step": { 12 | "user": { 13 | "title": "Конфігурація інвертора", 14 | "data": { 15 | "name": "Назва пристрою", 16 | "host": "Хост чи IP адреса", 17 | "port": "Порт", 18 | "transport": "Transport protocol", 19 | "lookup_file": "Профіль" 20 | }, 21 | "data_description": { 22 | "name": "Це ім'я буде використане як префікс до всіх сенсорів (змінюйте на свій розсуд)", 23 | "host": "Ім'я хоста або IP-адреса пристрою для підключення", 24 | "port": "Порт (8899) пристрою для підключення", 25 | "lookup_file": "yaml файл з визначеннями параметрів інвертора" 26 | }, 27 | "sections": { 28 | "additional_options": { 29 | "name": "Додаткові опції", 30 | "data": { 31 | "mod": "Модифікатор", 32 | "mppt": "Кількість МППТ", 33 | "phase": "Кількість фаз", 34 | "pack": "Кількість батарей", 35 | "battery_nominal_voltage": "Номінальний вольтаж літієвої батареї", 36 | "battery_life_cycle_rating": "Очікувана кількість циклів заряду/розряду літієвої батареї", 37 | "mb_slave_id": "Modbus Slave ID (зазвичай 1)" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | }, 44 | "options": { 45 | "error": { 46 | "cannot_connect": "Не вдалося підключитися", 47 | "invalid_host": "Не коректний хостнейм або IP-адреса", 48 | "unknown": "Неочікувана помилка" 49 | }, 50 | "step": { 51 | "init": { 52 | "title": "Конфігурація інвертора", 53 | "data": { 54 | "host": "Хост чи IP адреса", 55 | "port": "Порт", 56 | "transport": "Transport protocol", 57 | "lookup_file": "Профіль" 58 | }, 59 | "data_description": { 60 | "host": "Ім'я хоста або IP-адреса пристрою для підключення", 61 | "port": "Порт (8899) пристрою для підключення", 62 | "lookup_file": "yaml файл з визначеннями параметрів інвертора" 63 | }, 64 | "sections": { 65 | "additional_options": { 66 | "name": "Додаткові опції", 67 | "data": { 68 | "mod": "Модифікатор", 69 | "mppt": "Кількість МППТ", 70 | "phase": "Кількість фаз", 71 | "pack": "Кількість батарей", 72 | "battery_nominal_voltage": "Номінальний вольтаж літієвої батареї", 73 | "battery_life_cycle_rating": "Очікувана кількість циклів заряду/розряду літієвої батареї", 74 | "mb_slave_id": "Modbus Slave ID (зазвичай 1)" 75 | } 76 | } 77 | } 78 | } 79 | } 80 | }, 81 | "selector": { 82 | "transport": { 83 | "options": { 84 | "tcp": "TCP", 85 | "udp": "UDP", 86 | "modbus_tcp": "Modbus TCP" 87 | } 88 | } 89 | }, 90 | "entity": { 91 | "sensor": { 92 | "today_battery_charge": { 93 | "name": "Сьогоднішній заряд акумулятора" 94 | }, 95 | "today_battery_discharge": { 96 | "name": "Сьогоднішня розрядка батареї" 97 | }, 98 | "today_battery_life_cycles": { 99 | "name": "Сьогоднішні цикли батареї" 100 | }, 101 | "today_energy_export": { 102 | "name": "Сьогоднішній експорт енергії" 103 | }, 104 | "today_energy_import": { 105 | "name": "Сьогоднішній імпорт енергії" 106 | }, 107 | "today_generation": { 108 | "name": "Сьогоднішня генерація" 109 | }, 110 | "today_load_consumption": { 111 | "name": "Сьогоднішнє споживання навантаження" 112 | }, 113 | "today_losses": { 114 | "name": "Сьогоднішні втрати" 115 | }, 116 | "today_production": { 117 | "name": "Сьогоднішнє виробництво" 118 | }, 119 | "total_battery_charge": { 120 | "name": "Загальний заряд акумулятора" 121 | }, 122 | "total_battery_discharge": { 123 | "name": "Повний розряд акумулятора" 124 | }, 125 | "total_battery_life_cycles": { 126 | "name": "Загальна кількість циклів батареї" 127 | }, 128 | "total_energy_export": { 129 | "name": "Загальний експорт енергоносіїв" 130 | }, 131 | "total_energy_import": { 132 | "name": "Загальний імпорт енергоносіїв" 133 | }, 134 | "total_generation": { 135 | "name": "Загальна генерація" 136 | }, 137 | "total_load_consumption": { 138 | "name": "Загальне споживання навантаження" 139 | }, 140 | "total_losses": { 141 | "name": "Загальні втрати" 142 | }, 143 | "total_production": { 144 | "name": "Загальний обсяг виробництва" 145 | } 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Solarman", 3 | "zip_release": true, 4 | "filename": "solarman.zip", 5 | "homeassistant": "2025.2.0", 6 | "persistent_directory": "inverter_definitions/custom" 7 | } 8 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © 2024 David Rapan and Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ⚡ Solarman Stick Logger 2 | 3 | [![License](https://img.shields.io/github/license/davidrapan/ha-solarman)](https://github.com/davidrapan/ha-solarman/blob/main/license) 4 | [![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/davidrapan/ha-solarman/total)](https://github.com/davidrapan/ha-solarman/releases) 5 | [![GitHub Activity](https://img.shields.io/github/commit-activity/y/davidrapan/ha-solarman?label=commits)](https://github.com/davidrapan/ha-solarman/commits/main) 6 | [![HACS Supported](https://img.shields.io/badge/HACS-Supported-03a9f4)](https://github.com/custom-components/hacs) 7 | [![Community Forum](https://img.shields.io/badge/community-forum-03a9f4)](https://community.home-assistant.io/t/solarman-stick-logger-by-david-rapan) 8 | [![Discussions](https://img.shields.io/badge/discussions-orange)](https://github.com/davidrapan/ha-solarman/discussions) 9 | [![Wiki](https://img.shields.io/badge/wiki-8A2BE2)](https://github.com/davidrapan/ha-solarman/wiki) 10 | 11 | #### 🠶 Signpost 12 | - [Automations](https://github.com/davidrapan/ha-solarman/wiki/Automations) 13 | - [Custom Sensors](https://github.com/davidrapan/ha-solarman/wiki/Custom-Sensors) 14 | - [Dashboards](https://github.com/davidrapan/ha-solarman/wiki/Dashboards) 15 | - [Documentation](https://github.com/davidrapan/ha-solarman/wiki/Documentation) 16 | - [Naming Scheme](https://github.com/davidrapan/ha-solarman/wiki/Naming-Scheme) 17 | - [Supported Devices](https://github.com/davidrapan/ha-solarman/wiki/Supported-Devices) 18 | 19 | > [!IMPORTANT] 20 | > - Made for [🏡 Home Assistant](https://www.home-assistant.io/) 21 | > - Read about [✍ crucial changes & new features](https://github.com/davidrapan/ha-solarman/wiki#-changes) 22 | > - Implemented using asynchronous [pysolarmanv5](https://github.com/jmccrohan/pysolarmanv5) and even supports Ethernet Loggers 23 | 24 | > [!NOTE] 25 | > - It's still 🚧 work in progress but currently very 🐎 stable 😉 26 | > - If you are curious about what's planned next look into [🪧 Milestones](https://github.com/davidrapan/ha-solarman/milestones) 27 | > - Use [💬 Discussions](https://github.com/davidrapan/ha-solarman/discussions) for 🙏 Q&A, 💡 Development Planning and 🎁 feature requests, etc. and [🚩 Issues](https://github.com/davidrapan/ha-solarman/issues) for 🐞 bug reporting and such... 28 | 29 | ## 🔌 Installation 30 | 31 | [![🔌 Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=davidrapan&repository=ha-solarman&category=integration) 32 | 33 | - Go to Home Assistant Community Store 34 | - Search for and open **Solarman** repository 35 | - Make sure it's the right one (using displayed frontpage) and click DOWNLOAD 36 | 37 | ### 🛠 Manually 38 | - Copy the contents of `custom_components/solarman` to `/config/custom_components/solarman` in Home Assistant 39 | 40 | ## ⚙️ Configuration 41 | 42 | [![⚙️ Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=solarman) 43 | 44 | - Go to Settings > Devices & services > Integrations 45 | - Click ADD INTEGRATION, search for and select **Solarman** 46 | - Enter the appropriate details (should be autodiscovered under most circumstances) and click SUBMIT 47 | 48 | ## 👤 Contributors 49 | 50 | 51 | 52 |
53 |
54 | 55 | -------------------------------------------------------------------------------- /tools/discovery.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import asyncio 3 | 4 | from argparse import ArgumentParser 5 | 6 | DISCOVERY_IP = "255.255.255.255" 7 | DISCOVERY_PORT = 48899 8 | DISCOVERY_MESSAGE = ["WIFIKIT-214028-READ".encode(), "HF-A11ASSISTHREAD".encode()] 9 | DISCOVERY_TIMEOUT = 1 10 | 11 | class DiscoveryProtocol: 12 | def __init__(self, addresses: list[str] | str): 13 | self.addresses = addresses 14 | self.responses: asyncio.Queue = asyncio.Queue() 15 | self.transport: asyncio.DatagramTransport | None = None 16 | 17 | def connection_made(self, transport: asyncio.DatagramTransport): 18 | self.transport = transport 19 | print(f"DiscoveryProtocol: Send to {self.addresses}") 20 | for address in self.addresses if isinstance(self.addresses, list) else [self.addresses]: 21 | for message in DISCOVERY_MESSAGE: 22 | self.transport.sendto(message, (address, DISCOVERY_PORT)) 23 | 24 | def datagram_received(self, data: bytes, addr: tuple[str, int]): 25 | if len(d := data.decode().split(',')) == 3 and (s := int(d[2])): 26 | self.responses.put_nowait((s, {"ip": d[0], "mac": d[1]})) 27 | print(f"DiscoveryProtocol: [{d[0]}, {d[1]}, {s}] from {addr}") 28 | 29 | def error_received(self, e: OSError): 30 | print(f"DiscoveryProtocol: {e!r}") 31 | 32 | def connection_lost(self, _: Exception | None): 33 | print(f"DiscoveryProtocol: Connection closed") 34 | 35 | async def main(): 36 | parser = ArgumentParser( 37 | "solarman-discovery", description = "Discovery for Solarman Stick Loggers" 38 | ) 39 | parser.add_argument("--address", default = DISCOVERY_IP, required = False, help = "Network IPv4 address", type = str) 40 | parser.add_argument("--wait", default = True, required = False, help = "Gather responses until timeout", type = bool) 41 | args = parser.parse_args() 42 | 43 | try: 44 | transport, protocol = await asyncio.get_running_loop().create_datagram_endpoint(lambda: DiscoveryProtocol(args.address), family = socket.AF_INET, allow_broadcast = True) 45 | r = None 46 | while r is None or args.wait: 47 | r = await asyncio.wait_for(protocol.responses.get(), DISCOVERY_TIMEOUT) 48 | except TimeoutError: 49 | pass 50 | except Exception as e: 51 | print(repr(e)) 52 | finally: 53 | transport.close() 54 | 55 | if __name__ == "__main__": 56 | asyncio.run(main()) 57 | -------------------------------------------------------------------------------- /tools/discovery_reply.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import asyncio 3 | import netifaces 4 | 5 | DISCOVERY_IP = "0.0.0.0" 6 | DISCOVERY_PORT = 48899 7 | DISCOVERY_MESSAGE = ["WIFIKIT-214028-READ".encode(), "HF-A11ASSISTHREAD".encode()] 8 | DISCOVERY_TIMEOUT = 3600 9 | 10 | ifaces = netifaces.ifaddresses(netifaces.gateways()['default'][2][1]) 11 | iface_inet = ifaces[netifaces.AF_INET][0]["addr"] 12 | iface_link = ifaces[netifaces.AF_LINK][0]["addr"].replace(':', '').upper() 13 | 14 | DISCOVERY_MESSAGE_REPLY = f"{iface_inet},{iface_link},1234567890".encode() 15 | 16 | class DiscoveryProtocol: 17 | def connection_made(self, transport: asyncio.DatagramTransport): 18 | self.transport = transport 19 | 20 | def datagram_received(self, data: bytes, addr: tuple[str, int]): 21 | if data in DISCOVERY_MESSAGE: 22 | print(f"DiscoveryProtocol: {data} from {addr}") 23 | self.transport.sendto(DISCOVERY_MESSAGE_REPLY, addr) 24 | 25 | def error_received(self, e: OSError): 26 | print(f"DiscoveryProtocol: {e!r}") 27 | 28 | def connection_lost(self, _: Exception | None): 29 | print(f"DiscoveryProtocol: Connection closed") 30 | 31 | async def main(): 32 | transport, _ = await asyncio.get_running_loop().create_datagram_endpoint(DiscoveryProtocol, local_addr = (DISCOVERY_IP, DISCOVERY_PORT), family = socket.AF_INET, allow_broadcast = True) 33 | 34 | try: 35 | await asyncio.sleep(DISCOVERY_TIMEOUT) 36 | finally: 37 | transport.close() 38 | 39 | if __name__ == '__main__': 40 | asyncio.run(main()) 41 | -------------------------------------------------------------------------------- /tools/scheduler.py: -------------------------------------------------------------------------------- 1 | # 2 | # Command: py scheduler.py {path} {span} {runtime} 3 | # Example: py scheduler.py "..\custom_components\solarman\inverter_definitions\deye_sg04lp3.yaml" 25 0 4 | # span: Min span between registers to assume single request 5 | # runtime: Runtime mod update_interval 6 | # 7 | 8 | import os 9 | import sys 10 | import yaml 11 | import bisect 12 | 13 | from typing import Any 14 | 15 | def bulk_inherit(target: dict, source: dict, *keys: list): 16 | for k in source.keys() if len(keys) == 0 else source.keys() & keys: 17 | if not k in target and (v := source.get(k)) is not None: 18 | target[k] = v 19 | return target 20 | 21 | def unwrap(source: dict, key: Any, mod: int = 0): 22 | if (c := source.get(key)) is not None and isinstance(c, list): 23 | source[key] = c[mod] if mod < len(c) else c[-1] 24 | return source 25 | 26 | def entity_key(object: dict): 27 | return '_'.join(filter(None, (object["name"], object["platform"]))).lower().replace(' ', '_') 28 | 29 | def preprocess_descriptions(item, group, table, code, parameters): 30 | def modify(source: dict): 31 | for i in dict(source): 32 | if i in ("scale", "min", "max"): 33 | unwrap(source, i, parameters["mod"]) 34 | if i == "registers" and source[i] and (isinstance(source[i], list) and isinstance(source[i][0], list)): 35 | unwrap(source, i, parameters["mod"]) 36 | if not source[i]: 37 | source["disabled"] = True 38 | elif isinstance(source[i], dict): 39 | modify(source[i]) 40 | 41 | if not "platform" in item: 42 | item["platform"] = "sensor" if not "configurable" in item else "number" 43 | 44 | item["key"] = entity_key(item) 45 | 46 | modify(item) 47 | 48 | if (sensors := item.get("sensors")) and (registers := item.setdefault("registers", [])) is not None: 49 | registers.clear() 50 | for s in sensors: 51 | modify(s) 52 | if r := s.get("registers"): 53 | registers.extend(r) 54 | if m := s.get("multiply"): 55 | modify(m) 56 | if m_r := m.get("registers"): 57 | registers.extend(m_r) 58 | 59 | g = dict(group) 60 | g.pop("items") 61 | bulk_inherit(item, g, *() if "registers" in item else "update_interval") 62 | 63 | if not "code" in item and (r := item.get("registers")) and (addr := min(r)) is not None: 64 | item["code"] = table.get(addr, code) 65 | 66 | if sensors := item.get("sensors"): 67 | for s in sensors: 68 | bulk_inherit(s, item, "code", "scale") 69 | if m := s.get("multiply"): 70 | bulk_inherit(m, s, "code", "scale") 71 | 72 | return item 73 | 74 | def get_request_code(request): 75 | return request["code"] if "code" in request else request["mb_functioncode"] 76 | 77 | def get_code(item, type, default = None): 78 | if "code" in item and (code := item["code"]): 79 | if isinstance(code, int): 80 | if type == "read": 81 | return code 82 | elif type in code: 83 | return code[type] 84 | return default 85 | 86 | def all_same(values): 87 | return all(i == values[0] for i in values) 88 | 89 | def group_when(iterable, predicate): 90 | i, x, size = 0, 0, len(iterable) 91 | while i < size - 1: 92 | #print(f"{iterable[i]} and {iterable[i + 1]} = {predicate(iterable[i], iterable[i + 1], iterable[x])}") 93 | if predicate(iterable[i], iterable[i + 1], iterable[x]): 94 | yield iterable[x:i + 1] 95 | x = i + 1 96 | i += 1 97 | yield iterable[x:size] 98 | 99 | if __name__ == '__main__': 100 | 101 | if len(sys.argv) < 2: 102 | print("File not provided!") 103 | sys.exit() 104 | 105 | file = sys.argv[1] 106 | 107 | if not os.path.isfile(file): 108 | print("File does not exist!") 109 | sys.exit() 110 | 111 | span = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].lstrip('-').isnumeric() else 25 112 | 113 | runtime = int(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3].isnumeric() else 0 114 | 115 | with open(file) as f: 116 | profile = yaml.safe_load(f) 117 | 118 | _update_interval = 60 119 | _code = 0x00 120 | _max_size = 0 121 | if "default" in profile: 122 | default = profile["default"] 123 | _update_interval = default["update_interval"] if "update_interval" in default else 60 124 | _code = default["code"] if "code" in default else 0x03 125 | _max_size = default["max_size"] if "max_size" in default else 125 126 | 127 | table = {r: get_request_code(pr) for pr in profile["requests"] for r in range(pr["start"], pr["end"] + 1)} if "requests" in profile else {} 128 | 129 | parameters = {"mod": 0, "mppt": 2, "l": 3, "pack": 1} 130 | 131 | items = [i for i in sorted([preprocess_descriptions(item, group, table, _code, parameters) for group in profile["parameters"] for item in group["items"]], key = lambda x: (get_code(x, "read", _code), max(x["registers"])) if x.get("registers") else (-1, -1)) if len((a := i.keys() & parameters.keys())) == 0 or all(i[k] <= parameters[k] for k in a)] 132 | 133 | _is_single_code = False 134 | if (items_codes := [get_code(i, "read", _code) for i in items if "registers" in i]) and (is_single_code := all_same(items_codes)): 135 | _is_single_code = is_single_code 136 | _code = items_codes[0] 137 | 138 | registers = [] 139 | 140 | for i in items: 141 | if "name" in i and "rule" in i and not "disabled" in i and i["rule"] > 0: 142 | if "realtime" in i or (runtime % (i["update_interval"] if "update_interval" in i else _update_interval) == 0): 143 | if "registers" in i: 144 | print(f"{i["name"]}: {i["registers"]}") 145 | if "sensors" in i: 146 | print(i["sensors"]) 147 | for r in sorted(i["registers"]): 148 | if (register := (get_code(i, "read"), r)) and not register in registers: 149 | bisect.insort(registers, register) 150 | 151 | l = (lambda x, y: y - x > span) if span > -1 else (lambda x, y: False) 152 | 153 | _lambda = lambda x, y, z: l(x[1], y[1]) or y[1] - z[1] >= _max_size 154 | _lambda_code_aware = lambda x, y, z: x[0] != y[0] or _lambda(x, y, z) 155 | 156 | groups = group_when(registers, _lambda if _is_single_code or all_same([r[0] for r in registers]) else _lambda_code_aware) 157 | 158 | msg = '' 159 | 160 | for r in groups: 161 | if len(r) > 0: 162 | start = r[0][1] 163 | end = r[-1][1] 164 | dict = { "code": _code if _is_single_code else r[0][0], "start": start, "end": end, "len": end - start + 1 } 165 | msg += f'{dict}\n' 166 | 167 | print("") 168 | 169 | print(msg) 170 | --------------------------------------------------------------------------------