Skip to content

Conversation

@Revilo91
Copy link
Contributor

I noticed that the outside temperature from the ComfoClime_api was unfortunately being received as a signed number. This meant that it was suddenly showing 6000°C outside. This pull request aims to fix that.

Copilot AI review requested due to automatic review settings November 23, 2025 11:25
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a bug where negative outdoor temperatures from the ComfoClime API were being displayed incorrectly (e.g., 6000°C instead of negative values). The fix introduces a new _decode_raw_value function to handle signed/unsigned integer conversion with two's complement, and applies it automatically to temperature fields in the dashboard data.

Key Changes:

  • Added _decode_raw_value function to handle signed integer decoding with auto-scaling detection
  • Modified get_dashboard_data to automatically decode temperature fields (those ending with "Temperature")
  • Updated comments in set_property_for_device from German to English

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 10 to 39
SCALED_VALUE_THRESHOLD = 1000 # Threshold for already scaled values (e.g., temperature fields)


def _decode_raw_value(raw, factor=0.1, signed=True, byte_count=None):
"""Decode raw sensor values with auto-scaling and signed/unsigned conversion.

Note: The heuristic 'Small values (< 1000) → return as-is (already scaled)' is intended for temperature fields,
which are typically already scaled by the API. For other sensor types (e.g., fan speed, percentage values),
this may not be correct and could result in incorrect decoding (e.g., a raw value of 200 may need decoding).
Large values (≥ 1000) → decode as signed/unsigned int and apply factor.

Args:
raw: Raw value to decode
factor: Scaling factor
signed: Whether to interpret value as signed (default True)
byte_count: Number of bytes (1 or 2), if known
"""
if raw is None:
return None

try:
# Already scaled values pass through
if abs(raw) < SCALED_VALUE_THRESHOLD:
return float(raw)

# Apply two's complement for signed conversion
if 0x80 <= raw_int <= 0xFF: # 1-byte signed
raw_int -= 0x100
elif 0x8000 <= raw_int <= 0xFFFF: # 2-byte signed
raw_int -= 0x10000
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

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

[nitpick] The heuristic using SCALED_VALUE_THRESHOLD = 1000 is a fragile approach. As noted in the docstring, this assumes temperature fields are already scaled, but may fail for other sensor types. This creates a maintainability issue where:

  1. The threshold is a magic number without clear justification
  2. The logic is tightly coupled to temperature behavior
  3. Future sensor types could be mishandled

Consider either:

  • Making the threshold configurable per sensor type, or
  • Using explicit metadata to indicate whether a value is already scaled, or
  • Documenting which specific API response fields are pre-scaled vs. raw
Suggested change
SCALED_VALUE_THRESHOLD = 1000 # Threshold for already scaled values (e.g., temperature fields)
def _decode_raw_value(raw, factor=0.1, signed=True, byte_count=None):
"""Decode raw sensor values with auto-scaling and signed/unsigned conversion.
Note: The heuristic 'Small values (< 1000) → return as-is (already scaled)' is intended for temperature fields,
which are typically already scaled by the API. For other sensor types (e.g., fan speed, percentage values),
this may not be correct and could result in incorrect decoding (e.g., a raw value of 200 may need decoding).
Large values (≥ 1000) → decode as signed/unsigned int and apply factor.
Args:
raw: Raw value to decode
factor: Scaling factor
signed: Whether to interpret value as signed (default True)
byte_count: Number of bytes (1 or 2), if known
"""
if raw is None:
return None
try:
# Already scaled values pass through
if abs(raw) < SCALED_VALUE_THRESHOLD:
return float(raw)
# Apply two's complement for signed conversion
if 0x80 <= raw_int <= 0xFF: # 1-byte signed
raw_int -= 0x100
elif 0x8000 <= raw_int <= 0xFFFF: # 2-byte signed
raw_int -= 0x10000
# Mapping of sensor types to scaling metadata
SENSOR_TYPE_METADATA = {
# Example: 'temperature': {'already_scaled': True},
# 'fan_speed': {'already_scaled': False, 'factor': 0.1, 'signed': True},
# Add more sensor types as needed
'temperature': {'already_scaled': True},
'fan_speed': {'already_scaled': False, 'factor': 0.1, 'signed': True},
'percentage': {'already_scaled': False, 'factor': 1.0, 'signed': False},
# etc.
}
def _decode_raw_value(raw, sensor_type='temperature', factor=0.1, signed=True, byte_count=None):
"""Decode raw sensor values using sensor type metadata for scaling and signed/unsigned conversion.
Args:
raw: Raw value to decode
sensor_type: Type of sensor (e.g., 'temperature', 'fan_speed', 'percentage')
factor: Scaling factor (overrides metadata if provided)
signed: Whether to interpret value as signed (overrides metadata if provided)
byte_count: Number of bytes (1 or 2), if known
Returns:
Decoded value as float, or None if decoding fails.
"""
if raw is None:
return None
# Get metadata for sensor type
metadata = SENSOR_TYPE_METADATA.get(sensor_type, {})
already_scaled = metadata.get('already_scaled', False)
factor = metadata.get('factor', factor)
signed = metadata.get('signed', signed)
try:
if already_scaled:
return float(raw)
raw_int = int(raw)

Copilot uses AI. Check for mistakes.
Comment on lines 90 to 96
# Auto-decode only temperature fields (ending with 'Temperature')
return {
key: _decode_raw_value(val, factor=0.1)
if isinstance(val, (int, float)) and key.endswith("Temperature")
else val
for key, val in data.items()
}
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

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

[nitpick] The auto-decoding in get_dashboard_data only applies to fields ending with "Temperature", but the _decode_raw_value function is designed with a broader heuristic (the SCALED_VALUE_THRESHOLD check). This creates confusion about the function's intended scope. Consider either:

  1. Renaming _decode_raw_value to _decode_temperature_value to make its purpose clearer, or
  2. Removing the temperature-specific heuristic from the function and handling it at the call site

Copilot uses AI. Check for mistakes.
Comment on lines 36 to 39
if 0x80 <= raw_int <= 0xFF: # 1-byte signed
raw_int -= 0x100
elif 0x8000 <= raw_int <= 0xFFFF: # 2-byte signed
raw_int -= 0x10000
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

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

The variable raw_int is used before it's defined. On line 32, the code checks if abs(raw) < SCALED_VALUE_THRESHOLD, but then on lines 36-39 it tries to use raw_int which hasn't been assigned yet. You need to add raw_int = int(raw) before line 32 or at least before line 36.

Copilot uses AI. Check for mistakes.
Comment on lines 35 to 40
# Apply two's complement for signed conversion
if 0x80 <= raw_int <= 0xFF: # 1-byte signed
raw_int -= 0x100
elif 0x8000 <= raw_int <= 0xFFFF: # 2-byte signed
raw_int -= 0x10000

Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

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

This code block applies two's complement conversion to raw_int, but this happens before the variable is converted to an integer in the logic flow. Additionally, this entire block (lines 35-39) appears redundant because the same conversion is applied again later (lines 48-53). This duplicate logic should be removed to avoid confusion and potential double-conversion errors.

Suggested change
# Apply two's complement for signed conversion
if 0x80 <= raw_int <= 0xFF: # 1-byte signed
raw_int -= 0x100
elif 0x8000 <= raw_int <= 0xFFFF: # 2-byte signed
raw_int -= 0x10000

Copilot uses AI. Check for mistakes.
Comment on lines 41 to 46
# Determine byte_count if not provided
if byte_count is None:
if raw_int <= 0xFF:
byte_count = 1
elif raw_int <= 0xFFFF:
byte_count = 2
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

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

The byte_count determination logic uses raw_int which hasn't been defined yet at this point in the code. The variable needs to be initialized before this check. Additionally, the condition if raw_int <= 0xFF will always be true for negative values after two's complement conversion has been applied (which shouldn't have happened yet anyway due to the missing initialization).

Copilot uses AI. Check for mistakes.
@msfuture
Copy link
Owner

Which Sensor exactly is pushing wrong data? The API is already capable to handle negative values. We should Look for the bug in that code instead of building new functions to handle that. The scale factor should Not be guessed depending on values, as there are telemetries, that output high values without the need to be scaled. All sensors must be defined with the correct scale and sign config.

@msfuture
Copy link
Owner

Could you provide the output of your Dashboard JSON? As this isn't happening on my side.

@Revilo91
Copy link
Contributor Author

Revilo91 commented Nov 23, 2025

image

Here you can see at outdoorTemperature.. And I just noticed that it gets so hot (when it's charged like that, it's 6553.2°C).

I have no idea why that is...

@Revilo91
Copy link
Contributor Author

Which Sensor exactly is pushing wrong data? The API is already capable to handle negative values. We should Look for the bug in that code instead of building new functions to handle that. The scale factor should Not be guessed depending on values, as there are telemetries, that output high values without the need to be scaled. All sensors must be defined with the correct scale and sign config.

Okay, I understand.

Then the DASHBOARD_SENSORS would need to be adjusted, right? What I noticed during development is that positive values ​​have a factor of 1, but negative values ​​have a factor of 0.1. Unfortunately, that led me to write this additional function.

How else do you think this could be implemented?


What's also interesting is that when I retrieve the value from my ComfoAirQ via http://%IP%/device/%UUID_ComfoAirQ%/telemetry/276, the following JSON is returned:
e.g.: {"data":[251,255]} -> -0.5°C

This would match my observation from above.

@msfuture
Copy link
Owner

Wow, that's pretty fucked up in Zehnders Firmware. Can you record for a few values the output of the Dashboard and what the telemetry answers? The scale factor isn't changing, they just ignore the sign bit in the JSON Output. I think we can compensate that quote easy

@Revilo91
Copy link
Contributor Author

Can you reconstruct this on your system or is it not so cold at your home? 😉

I'll log in a few measurements, just to understand that myself.

@msfuture
Copy link
Owner

My supply pipe is buried about 30m outside in the garden to pre-warm in winter and pre-cool in summer. Even at -14°C peak this night near Ingolstadt, I had +4°C in the supply chain.

@msfuture
Copy link
Owner

The issue - as I see - is:

  • Zehnder is converting the measured temperature (2 bytes) directly to an int
  • they ignore, that this is a signed word and treat it as unsigned
  • after conversion they divide the value by 10

What's happening now:
Let's take a value of -2.5°C. Without division that's -25°C, converted to a signed word that's 11111111 11100111. If you treat that as unsigned, you get 65511, divided by 10 6551,1°C. This way we get this strange readings.

@msfuture
Copy link
Owner

As this will only happen with the outdoorTemperature I would recommend, that we just do:

  • check only outdoorTemperature value, if it's > 3276.7 (last representable value in signed word)
  • when it's larger: correct the value...

We could (only for the outdoorTemperature key) pass it through the function:

def fix_temperature(api_value):
    raw_value = int(api_value * 10)
    if raw_value > 32767:
        raw_value -= 65536
    return raw_value / 10.0

@Revilo91
Copy link
Contributor Author

My supply pipe is buried about 30m outside in the garden to pre-warm in winter and pre-cool in summer. Even at -14°C peak this night near Ingolstadt, I had +4°C in the supply chain.

That explains why this has not yet been noticed. Unfortunately, my connection is not so protected. Near Regensburg it was -8°C and the Comfoclime measured -7°C.

@Revilo91
Copy link
Contributor Author

As this will only happen with the outdoorTemperature I would recommend, that we just do:

  • check only outdoorTemperature value, if it's > 3276.7 (last representable value in signed word)
  • when it's larger: correct the value...

We could (only for the outdoorTemperature key) pass it through the function:

def fix_temperature(api_value):
    raw_value = int(api_value * 10)
    if raw_value > 32767:
        raw_value -= 65536
    return raw_value / 10.0

I'll take a closer look tomorrow and adapt my PR

- I've replaced the bitwise operations with Python-specific methods to make it more Pythonic.

- Additionally, I deleted a few lines that did not contribute to solving this problem.

- Revert: delete the property signed=True
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@msfuture
Copy link
Owner

As far as I can see, the "signed" argument when reading properties and telemetries is not passed to your new bytes_to_int function? That would treat every fetched value as signed, which is not correct. Or am I wrong?

@Revilo91
Copy link
Contributor Author

Also noticed and fixed this in this commit: ac2d83c

Change default value of 'signed' parameter to False.
@Revilo91
Copy link
Contributor Author

I think problems and doubts should now be resolved.

@msfuture
Copy link
Owner

In lines 149, 203 and 451 you call the conversion function but don't pass the argument signed. That's what I meant earlier.
That could lead to wrong readings when the value is indeed unsigned and very large. For example energy consumption values could get that high.

@Revilo91
Copy link
Contributor Author

In lines 149, 203 and 451 you call the conversion function but don't pass the argument signed. That's what I meant earlier. That could lead to wrong readings when the value is indeed unsigned and very large. For example energy consumption values could get that high.

Sorry, I completely overlooked that. Now the parameters will pass to the methods

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

self._request_lock = asyncio.Lock()

@staticmethod
def bytes_to_signed_int(data: list, byte_count: int = None, signed:bool = True) -> int:
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

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

Missing space after comma before signed parameter. Should be signed: bool with a space after the comma.

def bytes_to_signed_int(data: list, byte_count: int = None, signed: bool = True) -> int:

Copilot uses AI. Check for mistakes.
else:
raise ValueError(f"Nicht unterstützte Byte-Anzahl: {byte_count}")

value = self.bytes_to_signed_int(data, byte_count,signed)
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

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

Missing space after comma before signed parameter. Should be self.bytes_to_signed_int(data, byte_count, signed) with a space after the comma.

value = self.bytes_to_signed_int(data, byte_count, signed)
Suggested change
value = self.bytes_to_signed_int(data, byte_count,signed)
value = self.bytes_to_signed_int(data, byte_count, signed)

Copilot uses AI. Check for mistakes.
if signed and value >= 0x8000:
value -= 0x10000
if byte_count in (1, 2):
value = self.bytes_to_signed_int(data, byte_count,signed)
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

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

Missing space after comma before signed parameter. Should be self.bytes_to_signed_int(data, byte_count, signed) with a space after the comma.

value = self.bytes_to_signed_int(data, byte_count, signed)

Copilot uses AI. Check for mistakes.
if signed and raw_value < 0:
raw_value += 0x10000
data = [raw_value & 0xFF, (raw_value >> 8) & 0xFF]
data = self.signed_int_to_bytes(raw_value, byte_count,signed)
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

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

Missing space after comma before signed parameter. Should be self.signed_int_to_bytes(raw_value, byte_count, signed) with a space after the comma.

data = self.signed_int_to_bytes(raw_value, byte_count, signed)
Suggested change
data = self.signed_int_to_bytes(raw_value, byte_count,signed)
data = self.signed_int_to_bytes(raw_value, byte_count, signed)

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +31
"""Convert raw bytes to a signed integer value.

Args:
data: List of bytes (integers 0-255)
byte_count: Number of bytes to read. If None calculate from data

Returns:
Signed integer value

Raises:
ValueError: If byte_count is not 1 or 2
"""
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

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

The docstring is missing documentation for the signed parameter. The Args section should include:

Args:
    data: List of bytes (integers 0-255)
    byte_count: Number of bytes to read. If None calculate from data
    signed: Whether to interpret the bytes as a signed integer (default: True)

Copilot uses AI. Check for mistakes.
@msfuture msfuture merged commit ea8acc0 into msfuture:main Dec 17, 2025
8 checks passed
@Revilo91 Revilo91 deleted the feature/comfoclime_negativ_temperature branch December 27, 2025 16:20
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.

2 participants