Skip to content

Conversation

@GraemeDBlue
Copy link

@GraemeDBlue GraemeDBlue commented Oct 9, 2025

Summary

  • Simplify the V1 methods by using single re-usable base methods
  • Add Mix/SPH to V1 and while retaining legacy Shinephone backwards compatibility
  • Improve some code style

Checklist

  • I've made sure the PR does small incremental changes. (new code additions are dificult to review when e.g. the entire repository got improved codestyle in the same PR.)
  • I've added/updated the relevant docs for code changes i've made.

@GraemeDBlue GraemeDBlue marked this pull request as draft October 9, 2025 09:48
@johanzander
Copy link
Contributor

Thanks for adding mix support! However, I have a concern about the API design:

Passing inverter_type as a parameter to every method call creates unnecessary coupling and repetition. As I wrote in previous feedback (even though AI helped me phrase it): Inverter type is a property of the inverter, not a parameter of each operation. It should be determined once and reused internally, not passed around repeatedly.

Here is how you could do it:

  class OpenApiV1(GrowattApi):
      def __init__(self, token):
          super().__init__(agent_identifier=self._create_user_agent())
          self.api_url = f"{self.server_url}v1/"
          self.session.headers.update({"token": token})
          self._device_type_cache = {}

      def device_list(self, plant_id):
          """Get devices and cache their types"""
          response = self.session.get(
              url=self._get_url("device/list"),
              params={"plant_id": plant_id, "page": "", "perpage": ""}
          )
          data = self._process_response(response.json(), "getting device list")

          # Cache device types (type field from API)
          for device in data.get('devices', []):
              self._device_type_cache[device['device_sn']] = device['type']

          return data

      def _get_device_type(self, device_sn):
          """Get cached device type"""
          if device_sn not in self._device_type_cache:
              raise GrowattParameterError(
                  f"Device type unknown for {device_sn}. Call device_list() first."
              )
          return self._device_type_cache[device_sn]

      def _map_type_to_enum(self, type_code):
          """Map numeric type to DeviceType enum"""
          # Based on device_list documentation (line 203-213)
          if type_code == 7:
              return DeviceType.MIN_TLX
          elif type_code in [2, 5, 6]:  # storage, sph, spa
              return DeviceType.MIX_SPH
          # ... other mappings
          else:
              raise GrowattParameterError(f"Unsupported device type: {type_code}")

      # PUBLIC API - user never passes device_type
      def device_detail(self, device_sn):
          """Get device details - automatically routes to correct endpoint"""
          type_code = self._get_device_type(device_sn)
          device_type = self._map_type_to_enum(type_code)
          return self._device_details(device_sn, device_type)

      def device_energy(self, device_sn):
          """Get device energy - automatically routes to correct endpoint"""
          type_code = self._get_device_type(device_sn)
          device_type = self._map_type_to_enum(type_code)
          return self._device_energy(device_sn, device_type)

      # INTERNAL methods (with underscore prefix) - these take device_type
      def _device_details(self, device_sn, device_type):
          """Internal: Get detailed data for a device"""
          if not isinstance(device_type, DeviceType):
              raise GrowattParameterError(f"Invalid device type: {device_type}")

          response = self.session.get(
              self._get_device_url(device_type, ApiDataType.BASIC_INFO),
              params={'device_sn': device_sn}
          )
          return self._process_response(response.json(), f"getting {device_type.name} 
  details")

      # BACKWARDS COMPATIBILITY - keep old method names
      def min_detail(self, device_sn):
          """Backwards compatible - prefer device_detail()"""
          return self._device_details(device_sn, DeviceType.MIN_TLX)

      def mix_detail(self, device_sn, plant_id=None):
          """Backwards compatible - prefer device_detail()"""
          return self._device_details(device_sn, DeviceType.MIX_SPH)

  Clean Usage

  # Get all devices (caches types automatically)
  devices = api.device_list(plant_id)

  # Clean, consistent API - works for ALL device types
  for device in devices['devices']:
      sn = device['device_sn']

      # User never thinks about type - just uses device_sn
      details = api.device_detail(sn)
      energy = api.device_energy(sn)
      settings = api.device_settings(sn)

  Key points:
  - Public methods (device_detail, device_energy) = no device_type parameter
  - Internal methods (_device_details, _device_energy) = have device_type parameter
  - Backwards compatibility kept with min_detail, mix_detail wrappers
  - Type determined once from device_list(), cached, and reused automatically

@GraemeDBlue
Copy link
Author

GraemeDBlue commented Oct 9, 2025

Thanks for adding mix support! However, I have a concern about the API design:

Passing inverter_type as a parameter to every method call creates unnecessary coupling and repetition. As I wrote in previous feedback (even though AI helped me phrase it): Inverter type is a property of the inverter, not a parameter of each operation. It should be determined once and reused internally, not passed around repeatedly.

Here is how you could do it:

  class OpenApiV1(GrowattApi):
      def __init__(self, token):
          super().__init__(agent_identifier=self._create_user_agent())
          self.api_url = f"{self.server_url}v1/"
          self.session.headers.update({"token": token})
          self._device_type_cache = {}

      def device_list(self, plant_id):
          """Get devices and cache their types"""
          response = self.session.get(
              url=self._get_url("device/list"),
              params={"plant_id": plant_id, "page": "", "perpage": ""}
          )
          data = self._process_response(response.json(), "getting device list")

          # Cache device types (type field from API)
          for device in data.get('devices', []):
              self._device_type_cache[device['device_sn']] = device['type']

          return data

      def _get_device_type(self, device_sn):
          """Get cached device type"""
          if device_sn not in self._device_type_cache:
              raise GrowattParameterError(
                  f"Device type unknown for {device_sn}. Call device_list() first."
              )
          return self._device_type_cache[device_sn]

      def _map_type_to_enum(self, type_code):
          """Map numeric type to DeviceType enum"""
          # Based on device_list documentation (line 203-213)
          if type_code == 7:
              return DeviceType.MIN_TLX
          elif type_code in [2, 5, 6]:  # storage, sph, spa
              return DeviceType.MIX_SPH
          # ... other mappings
          else:
              raise GrowattParameterError(f"Unsupported device type: {type_code}")

      # PUBLIC API - user never passes device_type
      def device_detail(self, device_sn):
          """Get device details - automatically routes to correct endpoint"""
          type_code = self._get_device_type(device_sn)
          device_type = self._map_type_to_enum(type_code)
          return self._device_details(device_sn, device_type)

      def device_energy(self, device_sn):
          """Get device energy - automatically routes to correct endpoint"""
          type_code = self._get_device_type(device_sn)
          device_type = self._map_type_to_enum(type_code)
          return self._device_energy(device_sn, device_type)

      # INTERNAL methods (with underscore prefix) - these take device_type
      def _device_details(self, device_sn, device_type):
          """Internal: Get detailed data for a device"""
          if not isinstance(device_type, DeviceType):
              raise GrowattParameterError(f"Invalid device type: {device_type}")

          response = self.session.get(
              self._get_device_url(device_type, ApiDataType.BASIC_INFO),
              params={'device_sn': device_sn}
          )
          return self._process_response(response.json(), f"getting {device_type.name} 
  details")

      # BACKWARDS COMPATIBILITY - keep old method names
      def min_detail(self, device_sn):
          """Backwards compatible - prefer device_detail()"""
          return self._device_details(device_sn, DeviceType.MIN_TLX)

      def mix_detail(self, device_sn, plant_id=None):
          """Backwards compatible - prefer device_detail()"""
          return self._device_details(device_sn, DeviceType.MIX_SPH)

  Clean Usage

  # Get all devices (caches types automatically)
  devices = api.device_list(plant_id)

  # Clean, consistent API - works for ALL device types
  for device in devices['devices']:
      sn = device['device_sn']

      # User never thinks about type - just uses device_sn
      details = api.device_detail(sn)
      energy = api.device_energy(sn)
      settings = api.device_settings(sn)

  Key points:
  - Public methods (device_detail, device_energy) = no device_type parameter
  - Internal methods (_device_details, _device_energy) = have device_type parameter
  - Backwards compatibility kept with min_detail, mix_detail wrappers
  - Type determined once from device_list(), cached, and reused automatically

@johanzander thanks for your comments, but there is no need to pass the type at all did you see

   plants = api.plant_list()
    print(f"Plants: Found {plants['count']} plants")
    plant_id = plants['plants'][0]['plant_id']
    today = datetime.date.today()
    devices = api.get_devices(plant_id)
    for device in devices:
        # Works automatically for MIN, MIX, or any future device type!
        details = device.details()
        energy = device.energy()
        settings = device.settings()
        history = device.energy_history(start_date=today)

raise Exception("No MIN_TLX device found to get energy data from.")

# energy data does not contain epvToday for some reason, so we need to calculate it
epv_today = energy_data["epv1Today"] + energy_data["epv2Today"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great to add this to the library. But I have up to 4 individual PVs, so please use something more dynamic, taking into account the sum of up to 4 if present.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@johanzander are they all of the same type or do you have a mix? that might be a interesting scenario

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a sum

@johanzander
Copy link
Contributor

but you have modified min_example.py to take device type parameter. is this a mistake?

inverter_data = api.min_detail(inverter_sn)
inverter_data = api.device_details(
device_sn=inverter_sn,
device_type=device_type
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

passing device_type?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let me check , but I believe I have 3 different ways to call the same methods , device_details() is the base method with sn and device type https://github.com/indykoning/PyPi_GrowattServer/pull/125/files#diff-12865f9986e5f28194397aae0efed2b7abc9137d18c0a09197682e6c7e65cd01R484-R511, the details() https://github.com/indykoning/PyPi_GrowattServer/pull/125/files#diff-12865f9986e5f28194397aae0efed2b7abc9137d18c0a09197682e6c7e65cd01R1225-R1227 where the sn and device type are set on the object, so not needed as params , I will roll back my changes in examples/min_example.py to use a backwards compatible min_detail(sn) which will just call device_details(sn, device_type=7 )

Copy link
Contributor

@johanzander johanzander left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backward compatibility with MIN inverters using V1 seem to work...
Will test the new harmonized V1 APIs later...

Comment on lines 152 to 155
if DEBUG >= 1:
print(f"Saving {self.slugify(operation_name)}_data.json") # noqa: T201
with open(f"{self.slugify(operation_name)}_data.json", "w") as f: # noqa: PTH123
json.dump(response, f, indent=4, sort_keys=True)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove debug code from prod version.
Im testing the library and I see these:
Saving getting_MIN_TLX_details_data.json
Saving getting_MIN_TLX_settings_data.json
Saving getting_MIN_TLX_energy_data_data.json

"""Enumeration of Growatt device types."""

MIX_SPH = 5 # MIX/SPH devices
MIN_TLX = 7 # MIN/TLX devices
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency, should you call the enum

MiX_SPH for SPH_MIX instead (<DEVICE_TYPE>)

consider adding all types in the enum:
INVERTER = 1
STORAGE = 2
OTHER = 3
MAX = 4
SPH_MIX = 5
SPA = 6
MIN_TLX = 7
PCS = 8
HPS = 9
PBD = 10

https://www.showdoc.com.cn/262556420217021/6117958613377445

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only thing consistent with their naming convention, is the inconsistency

@GraemeDBlue
Copy link
Author

@indykoning I have pulled the upstream changes in and fixed as many ruff errors as possible

@johanzander
Copy link
Contributor

Concerns with This PR

I really appreciate the effort to add MIX/SPH support, but I have concerns about the approach.

1. Breaking Changes Will Break Home Assistant Integration

The PR changes existing public method signatures:

Before (current):

api.min_read_parameter(device_sn, parameter_id, start_address=None, end_address=None)
api.min_write_time_segment(device_sn, segment_id, batt_mode, start_time, end_time, enabled=True)

After (this PR):

api.min_read_parameter(device_sn, params: dict)
api.min_write_time_segment(device_sn, params: TimeSegmentParams)

Impact: This will immediately break existing code including the Home Assistant Growatt integration which relies on this library. We need to maintain backward compatibility.

2. The "Generic" Methods Don't Actually Solve a Real Problem

The new common_read_parameter() and device_settings() methods aim to reduce duplication, but they're more verbose without adding value:

Current (clear and concise)

api.min_read_parameter(device_sn, 'discharge_power')

Proposed (verbose and exposes implementation details)

api.common_read_parameter(device_sn, DeviceType.MIN_TLX, 'discharge_power')

There's minimal code duplication being eliminated - just URL routing logic. MIN and SPH devices call completely different API endpoints (device/tlx/* vs device/mix/*, readMinParam vs readMixParam) with different parameters. The "generic" methods just add an abstraction layer that exposes device-type implementation details at every call.

3. Recommendation

To add SPH support while maintaining backward compatibility:

  1. Keep existing min_* method signatures unchanged
  2. Add parallel sph_* methods for SPH devices (following the official Growatt API naming for device type 5, or use mix_*):
# Settings
 def sph_settings(self, device_sn):
     """Get settings for an SPH inverter."""
     # SPH-specific implementation

 # Read/Write Parameters
 def sph_read_parameter(self, device_sn, parameter_id, start_address=None, end_address=None):
     """Read parameter from SPH inverter."""
     # SPH-specific implementation

 def sph_write_parameter(self, device_sn, parameter_id, parameter_values=None):
     """Write parameter to SPH inverter."""
     # SPH-specific implementation

 # Time Segments
 def sph_read_time_segments(self, device_sn, settings_data=None):
     """Read time segments from SPH inverter."""
     # SPH-specific implementation

 def sph_write_time_segment(self, device_sn, segment_id, batt_mode, start_time, end_time, enabled=True):
     """Write time segment to SPH inverter."""
     # SPH-specific implementation

This approach:

  • Maintains backward compatibility (doesn't break Home Assistant)
  • Provides clear, device-specific interfaces
  • Follows the existing min_* pattern users already understand
  • Uses official Growatt API naming (device type 5 = "sph")
  • Doesn't expose device-type implementation details at call sites
  • Covers all operations: settings, parameters, and time segments

The MIN and SPH implementations can share internal helper methods like _get_device_url() if needed, but the public API should remain device-specific and backward-compatible.

4. Alternative: If Truly Generic Methods Are Desired

If the goal is to have generic methods for parameter operations, a better approach would cache device types internally:

Usage:

 # Device types are auto-registered when calling device_list
 devices = api.device_list(plant_id)

 # Then use truly generic methods for parameters (no device_type parameter!)
 api.read_parameter(device_sn, 'discharge_power')
 api.write_parameter(device_sn, 'ac_charge', 1)

Implementation approach:

  • Add _device_type_cache dict that maps device_sn -> DeviceType
  • Auto-populate it in device_list() when devices are retrieved
  • Generic parameter methods look up device type from cache automatically
  • Optionally provide register_device(device_sn, device_type) for manual registration
  • Time segment methods remain device-specific since they may have different parameters

Benefits:

  • Truly generic API for common operations (no device_type at call sites)
  • Backward compatible (existing min_* methods unchanged)
  • Additive feature (doesn't break anything)
  • Cleaner than common_read_parameter(sn, device_type, param)
  • Device-specific methods remain available for operations with different signatures

This would provide a genuinely generic interface for parameter read/write operations while maintaining all existing functionality.


Would you be open to refactoring along these lines?

@GraemeDBlue
Copy link
Author

@johanzander so why did you not say this months ago, when you first looked at this?

@GraemeDBlue
Copy link
Author

@johanzander also saying it would break the HA integration, should not happen if the dependency is using correct SemVer dependency requirements right?

@johanzander
Copy link
Contributor

@GraemeDBlue I did provide feedback months ago about the API design concerns - specifically about passing device_type as a parameter to every method call rather than caching it internally.

However, I'll be honest: I didn't anticipate that existing min_* method signatures would be changed in a way that breaks backward compatibility. I assumed we'd be adding new functionality rather than modifying existing public APIs that downstream projects like Home Assistant depend on.

Looking back at the PR history, it appears some of the breaking signature changes (like min_read_parameter and min_write_time_segment taking params objects instead of individual arguments) may have been introduced since my earlier comments.

My feedback remains focused on two main points:

  1. Backward compatibility is critical - The existing min_* methods are in production use and changing their signatures will break existing code
  2. API design - If we want generic methods, caching device types internally provides a cleaner interface than requiring device_type as a parameter at every call site

I'm happy to discuss how we can add SPH support in a way that doesn't break existing integrations. The work you've done on the underlying implementation is valuable - it's really just about how we expose it as a public API.

@GraemeDBlue
Copy link
Author

@johanzander yeah I'm open to working together, but thought you had ghosted me, so stopped asking you for feedback.

@johanzander
Copy link
Contributor

johanzander commented Jan 5, 2026

also saying it would break the HA integration, should not happen if the dependency is using correct SemVer dependency requirements right?

Yes, it wouldn't break immediately because of these changes were commited or even this library released, but if the HA integration would be updated from current 1.7.1 to a newer version it would break.

In theory one could update the library version and the method calls in HA at the same time, but HA project don't allow this - they bump libraries in separate PRs from code updates.

@johanzander
Copy link
Contributor

@johanzander yeah I'm open to working together, but thought you had ghosted me, so stopped asking you for feedback.

No, I have been busy with the Home Assistant Growatt integration and finally all major changes I needed (reading / writing TOU settings, etc.) have been accepted and will be released in the 2026.1 update.

So now I will have more time to spend on this library update.

@johanzander
Copy link
Contributor

@GraemeDBlue - I'd like to help get this merged. If you're open to it, I can collaborate on updating the PR to:

  1. Restore the original min_* method signatures (backward compatibility)
  2. Add the new sph_* methods (following the same pattern)
  3. Keep your internal implementation work (which is solid)
  4. Optionally add generic methods for read/write settings

Would you be open to that? I can push changes to your branch or create a collaborative branch - whatever works best for you.

@GraemeDBlue
Copy link
Author

However, I'll be honest: I didn't anticipate that existing min_* method signatures would be changed in a way that breaks backward compatibility. I assumed we'd be adding new functionality rather than modifying existing public APIs that downstream projects like Home Assistant depend on.

Looking back at the PR history, it appears some of the breaking signature changes (like min_read_parameter and min_write_time_segment taking params objects instead of individual arguments) may have been introduced since my earlier comments.

@johanzander my idea had been to get the Growatt library to control and validate the params sent , rather than the HA implementation by calling specific library method like johanzander/growatt_server_upstream@master...GraemeDBlue:growatt_server_upstream:master#diff-814821c0de54a55c25b8b789f81ed1edcf1486fa245f99748de316999e503fc4R551

@GraemeDBlue
Copy link
Author

@GraemeDBlue - I'd like to help get this merged. If you're open to it, I can collaborate on updating the PR to:

  1. Restore the original min_* method signatures (backward compatibility)
  2. Add the new sph_* methods (following the same pattern)
  3. Keep your internal implementation work (which is solid)
  4. Optionally add generic methods for read/write settings

Would you be open to that? I can push changes to your branch or create a collaborative branch - whatever works best for you.

@johanzander yeah I can add you as a collaborator on my branch

@GraemeDBlue
Copy link
Author

@johanzander aah its not protected anyway so maybe we should just create a new branch and have some sort of plan rather than working cross purposes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants