Skip to content

Add temperature unit for climate and water heater#1586

Closed
jhenkens wants to merge 5 commits intoesphome:mainfrom
jhenkens:main
Closed

Add temperature unit for climate and water heater#1586
jhenkens wants to merge 5 commits intoesphome:mainfrom
jhenkens:main

Conversation

@jhenkens
Copy link
Copy Markdown
Contributor

@jhenkens jhenkens commented Apr 15, 2026

What does this implement/fix?

Exposes temperature unit for climate and water_heater, with corresponding changes in home-assistant/core, aioesphome, esphome, and esphome-docs, to enable native Fahrenheit measurements on those entities. Usecase is to enable accurate temperature settings on a Fahrenheit hot tub, controlled via RS485-UART. Rather than round tripped from F -> C -> C, with various floating point errors and weirdnesses, this just makes it stay F the entire way and work much more reliably.

Tests still need to be added where appropriate - that's on the todo - but the code has been validated.

When esphome is updated, but core is not, as long as you do not set the temperature unit on a climate/water heater, everything works the same as it currently is. If you do set it, you will convert from F -> "F", as home assistant will think the incoming value from ESPHome is C, without reading the UOM. If ESPHome does not have F specified, it continues to default to C.

When HA is updated, but ESPHome is on an old version, it maintains legacy behavior of treating all climate/water_heaters as using C.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Code quality improvements to existing code or addition of tests
  • Other

Related issue or feature (if applicable):

  • fixes

Pull request in esphome (if applicable):

home-assistant/core#168261
#1586
esphome/esphome#15815
esphome/esphome-docs#6463

Checklist:

  • The code change is tested and works locally.
  • If api.proto was modified, a linked pull request has been made to esphome with the same changes.
  • Tests have been added to verify that the new code works (under tests/ folder).

@jhenkens jhenkens marked this pull request as draft April 15, 2026 01:13
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 15, 2026

Walkthrough

This PR adds support for temperature unit tracking to climate and water heater entities by introducing a new TemperatureUnit enum in the protobuf schema and Python model, with corresponding fields added to ListEntitiesClimateResponse, ListEntitiesWaterHeaterResponse, and their dataclass counterparts. The package version was also updated to 0.1.0.

Changes

Cohort / File(s) Summary
Protocol Buffer Schema
aioesphomeapi/api.proto
Added TemperatureUnit enum with CELSIUS, FAHRENHEIT, and KELVIN values. Extended ListEntitiesClimateResponse with temperature_unit field and extended ListEntitiesWaterHeaterResponse with temperature_unit field.
Python Model
aioesphomeapi/model.py
Added TemperatureUnit integer enum. Added temperature_unit field to ClimateInfo and WaterHeaterInfo dataclasses with converter and CELSIUS default fallback.
Package Metadata
setup.py
Updated VERSION constant from "44.17.0" to "0.1.0".

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

Suggested labels

minor

Suggested reviewers

  • bdraco
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding temperature unit support for climate and water heater entities.
Description check ✅ Passed The description is directly related to the changeset, explaining the feature's purpose, use case, compatibility considerations, and referencing related PRs.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
aioesphomeapi/api.proto (1)

1096-1096: Consider documenting unit semantics on info fields.

Add a brief comment that climate/water-heater temperature values in state/command messages are expressed in this temperature_unit, to prevent consumer ambiguity.

Also applies to: 1196-1196

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@aioesphomeapi/api.proto` at line 1096, The proto field temperature_unit (type
ClimateTemperatureUnit) lacks unit semantics; add a brief comment above the
field(s) (e.g., the ClimateTemperatureUnit temperature_unit = 28; occurrences
and the similar occurrence around 1196) stating that all climate/water-heater
temperature values in related state and command messages are expressed in this
unit so consumers know how to interpret numeric temperature fields; reference
the symbol temperature_unit and ClimateTemperatureUnit in the comment for
clarity.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@aioesphomeapi/model.py`:
- Around line 743-745: The temperature enum fields are annotated as non-optional
but their converters (ClimateTemperatureUnit.convert and
WaterHeaterTemperatureUnit.convert) can return None; update the field
annotations temperature_unit (and the analogous water heater field) to be
Optional[ClimateTemperatureUnit] and Optional[WaterHeaterTemperatureUnit]
respectively, add/import typing.Optional if missing, and ensure the
converter_field calls remain unchanged so the post-init assignment of None
satisfies the type contract.

---

Nitpick comments:
In `@aioesphomeapi/api.proto`:
- Line 1096: The proto field temperature_unit (type ClimateTemperatureUnit)
lacks unit semantics; add a brief comment above the field(s) (e.g., the
ClimateTemperatureUnit temperature_unit = 28; occurrences and the similar
occurrence around 1196) stating that all climate/water-heater temperature values
in related state and command messages are expressed in this unit so consumers
know how to interpret numeric temperature fields; reference the symbol
temperature_unit and ClimateTemperatureUnit in the comment for clarity.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 93a5c055-bf97-4f7b-889a-4f3236deca20

📥 Commits

Reviewing files that changed from the base of the PR and between 77ca594 and 30f249c.

📒 Files selected for processing (3)
  • aioesphomeapi/api.proto
  • aioesphomeapi/api_pb2.py
  • aioesphomeapi/model.py

Comment thread aioesphomeapi/model.py Outdated
Comment on lines +743 to +745
temperature_unit: ClimateTemperatureUnit = converter_field(
default=ClimateTemperatureUnit.UNSET, converter=ClimateTemperatureUnit.convert
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify converter contract vs annotated field types in aioesphomeapi/model.py

rg -n -C2 "def convert\(cls: type\[_T\], value: int\) -> _T \| None" aioesphomeapi/model.py
rg -n -C2 "temperature_unit: ClimateTemperatureUnit|temperature_unit: WaterHeaterTemperatureUnit" aioesphomeapi/model.py
rg -n -C2 "converter=ClimateTemperatureUnit.convert|converter=WaterHeaterTemperatureUnit.convert" aioesphomeapi/model.py

Repository: esphome/aioesphomeapi

Length of output: 1122


🏁 Script executed:

# Find the converter_field definition and its implementation
rg -n "def converter_field" aioesphomeapi/model.py

# Check if converter_field is imported from elsewhere
rg -n "converter_field" aioesphomeapi/model.py | head -5

Repository: esphome/aioesphomeapi

Length of output: 436


🏁 Script executed:

# Search for imports at the top of the file
head -50 aioesphomeapi/model.py | grep -n "converter_field\|from\|import"

Repository: esphome/aioesphomeapi

Length of output: 571


🏁 Script executed:

# Look at the converter_field usage to understand its signature and behavior
rg -n -B5 "converter_field" aioesphomeapi/model.py | head -100

Repository: esphome/aioesphomeapi

Length of output: 3509


🏁 Script executed:

# Get the full converter_field function implementation
sed -n '90,110p' aioesphomeapi/model.py

Repository: esphome/aioesphomeapi

Length of output: 626


🏁 Script executed:

# Check if converter_field has any special handling for None values
rg -n -A20 "^def converter_field" aioesphomeapi/model.py

Repository: esphome/aioesphomeapi

Length of output: 700


🏁 Script executed:

# Search for where converters from metadata are applied
rg -n "metadata.*converter|converter.*metadata" aioesphomeapi/model.py

# Check APIModelBase methods
sed -n '60,90p' aioesphomeapi/model.py

Repository: esphome/aioesphomeapi

Length of output: 1299


🏁 Script executed:

# Search for usage of converter in metadata across the codebase
rg -n "\[\"converter\"\]|\[.converter.\]" aioesphomeapi/

Repository: esphome/aioesphomeapi

Length of output: 128


Fix type annotation for enum fields with nullable converters.

The convert() method on ClimateTemperatureUnit and WaterHeaterTemperatureUnit returns _T | None, but the temperature_unit fields at lines 743 and 1203 are annotated as non-optional. When __post_init__ applies the converter, a None value can be assigned to these fields, violating the type contract.

Update both fields to allow None:

Proposed fix
-    temperature_unit: ClimateTemperatureUnit = converter_field(
+    temperature_unit: ClimateTemperatureUnit | None = converter_field(
         default=ClimateTemperatureUnit.UNSET, converter=ClimateTemperatureUnit.convert
     )
-    temperature_unit: WaterHeaterTemperatureUnit = converter_field(
+    temperature_unit: WaterHeaterTemperatureUnit | None = converter_field(
         default=WaterHeaterTemperatureUnit.UNSET, converter=WaterHeaterTemperatureUnit.convert
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
temperature_unit: ClimateTemperatureUnit = converter_field(
default=ClimateTemperatureUnit.UNSET, converter=ClimateTemperatureUnit.convert
)
temperature_unit: ClimateTemperatureUnit | None = converter_field(
default=ClimateTemperatureUnit.UNSET, converter=ClimateTemperatureUnit.convert
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@aioesphomeapi/model.py` around lines 743 - 745, The temperature enum fields
are annotated as non-optional but their converters
(ClimateTemperatureUnit.convert and WaterHeaterTemperatureUnit.convert) can
return None; update the field annotations temperature_unit (and the analogous
water heater field) to be Optional[ClimateTemperatureUnit] and
Optional[WaterHeaterTemperatureUnit] respectively, add/import typing.Optional if
missing, and ensure the converter_field calls remain unchanged so the post-init
assignment of None satisfies the type contract.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 15, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (05da893) to head (dba7c4c).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##              main     #1586   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           25        25           
  Lines         4002      4008    +6     
=========================================
+ Hits          4002      4008    +6     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 15, 2026

Merging this PR will not alter performance

✅ 11 untouched benchmarks


Comparing jhenkens:main (dba7c4c) with main (05da893)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (c6ef4d6) during the generation of this report, so 05da893 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@jhenkens jhenkens force-pushed the main branch 4 times, most recently from 15b03e7 to c66f09e Compare April 18, 2026 14:51
@jhenkens jhenkens marked this pull request as ready for review April 18, 2026 14:53
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
setup.py (1)

44-60: ⚠️ Potential issue | 🟠 Major

Keep the package version on the existing 44.x release line.

Changing VERSION from 44.17.0 to 0.1.0 makes this feature release look older than the current published series, so normal dependency upgrades may never select it; it also changes DOWNLOAD_URL to the 0.1.0 archive path. Please restore the existing version stream and bump to the intended next release version.

Proposed fix
-VERSION = "0.1.0"
+VERSION = "44.18.0"

If the project uses a different release cadence, use the next valid 44.x version instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@setup.py` around lines 44 - 60, The VERSION constant was mistakenly set to
"0.1.0"; restore it to the project's 44.x release line (e.g., set VERSION back
to the previous 44.17.0 or bump to the intended next 44.x release) so dependency
resolution and DOWNLOAD_URL remain correct; update the VERSION value used by
DOWNLOAD_URL (and verify DOWNLOAD_URL still points to
f"{GITHUB_URL}/archive/{VERSION}.zip") to reflect the corrected 44.x version.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@setup.py`:
- Around line 44-60: The VERSION constant was mistakenly set to "0.1.0"; restore
it to the project's 44.x release line (e.g., set VERSION back to the previous
44.17.0 or bump to the intended next 44.x release) so dependency resolution and
DOWNLOAD_URL remain correct; update the VERSION value used by DOWNLOAD_URL (and
verify DOWNLOAD_URL still points to f"{GITHUB_URL}/archive/{VERSION}.zip") to
reflect the corrected 44.x version.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ad8f6789-d3eb-4f05-bd4b-956e8f81eafa

📥 Commits

Reviewing files that changed from the base of the PR and between c66f09e and f5fd81e.

📒 Files selected for processing (4)
  • aioesphomeapi/api.proto
  • aioesphomeapi/api_pb2.py
  • aioesphomeapi/model.py
  • setup.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • aioesphomeapi/model.py
  • aioesphomeapi/api.proto

Comment thread setup.py


VERSION = "44.17.0"
VERSION = "0.1.0"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please remove this change

Comment thread aioesphomeapi/api.proto
Copy link
Copy Markdown
Member

@bdraco bdraco left a comment

Choose a reason for hiding this comment

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

Please see comments above

@esphome
Copy link
Copy Markdown

esphome Bot commented Apr 22, 2026

Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍

Learn more about our pull request process.

@esphome esphome Bot marked this pull request as draft April 22, 2026 01:09
@bdraco bdraco requested a review from Copilot April 22, 2026 01:11
Comment thread setup.py
Copy link
Copy Markdown
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 adds support for representing the temperature unit (C/F/K) on Climate and Water Heater entities in the aioesphomeapi client models and protocol, enabling native Fahrenheit flows end-to-end.

Changes:

  • Add TemperatureUnit enum to the API protocol (api.proto) and regenerate protobuf bindings (api_pb2.py).
  • Expose temperature_unit on ClimateInfo and WaterHeaterInfo in aioesphomeapi/model.py.
  • Modify package version in setup.py.

Reviewed changes

Copilot reviewed 2 out of 3 changed files in this pull request and generated 4 comments.

File Description
setup.py Updates package version used for releases/build metadata.
aioesphomeapi/model.py Adds TemperatureUnit model enum and new temperature_unit fields on entity info models.
aioesphomeapi/api.proto Extends the protobuf API with a new enum and new fields on climate/water_heater entity info messages.
aioesphomeapi/api_pb2.py Regenerated protobuf Python output reflecting the updated api.proto.

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

Comment thread setup.py Outdated
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

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

VERSION was changed from the project’s current release series to "0.1.0", which will break packaging/version ordering (pip will treat this as a major downgrade) and produce an incorrect download URL. Please restore the correct versioning scheme and bump appropriately for this feature release (e.g., next patch/minor in the existing 44.x line).

Suggested change
VERSION = "44.1.0"

Copilot uses AI. Check for mistakes.
Comment thread aioesphomeapi/model.py
Comment on lines +742 to +747
temperature_unit: TemperatureUnit = (
converter_field(
default=TemperatureUnit.CELSIUS,
converter=TemperatureUnit.convert,
)
or TemperatureUnit.CELSIUS
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

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

The or TemperatureUnit.CELSIUS fallback here is ineffective because it runs at class definition time and converter_field(...) is always truthy. Also, TemperatureUnit.convert can return None for unknown enum values, but this field is not typed as optional, so future/invalid values could result in temperature_unit=None unexpectedly. Consider either making the type TemperatureUnit | None (consistent with other converted enums) or using a converter that falls back to TemperatureUnit.CELSIUS when conversion fails, and drop the redundant or ....

Suggested change
temperature_unit: TemperatureUnit = (
converter_field(
default=TemperatureUnit.CELSIUS,
converter=TemperatureUnit.convert,
)
or TemperatureUnit.CELSIUS
temperature_unit: TemperatureUnit = converter_field(
default=TemperatureUnit.CELSIUS,
converter=lambda value: TemperatureUnit.convert(value)
or TemperatureUnit.CELSIUS,

Copilot uses AI. Check for mistakes.
Comment thread aioesphomeapi/model.py
Comment on lines +1199 to +1204
temperature_unit: TemperatureUnit = (
converter_field(
default=TemperatureUnit.CELSIUS,
converter=TemperatureUnit.convert,
)
or TemperatureUnit.CELSIUS
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

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

Same issue as ClimateInfo: converter_field(...) or TemperatureUnit.CELSIUS is redundant, and TemperatureUnit.convert may yield None for unknown values even though this field is not optional. Please align with the project’s usual enum-conversion pattern (optional type or safe fallback converter) and remove the ineffective or expression.

Suggested change
temperature_unit: TemperatureUnit = (
converter_field(
default=TemperatureUnit.CELSIUS,
converter=TemperatureUnit.convert,
)
or TemperatureUnit.CELSIUS
temperature_unit: TemperatureUnit = converter_field(
default=TemperatureUnit.CELSIUS,
converter=lambda value: TemperatureUnit.convert(value)
or TemperatureUnit.CELSIUS,

Copilot uses AI. Check for mistakes.
Comment thread aioesphomeapi/model.py
Comment on lines +742 to +747
temperature_unit: TemperatureUnit = (
converter_field(
default=TemperatureUnit.CELSIUS,
converter=TemperatureUnit.convert,
)
or TemperatureUnit.CELSIUS
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

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

New behavior is added (temperature_unit for climate/water_heater), but there are no targeted tests asserting correct conversion/round-tripping (e.g., from_pb / from_dict with Fahrenheit, and handling of unknown enum values). Since this repo has extensive model conversion tests, please add coverage for these new fields to prevent regressions.

Suggested change
temperature_unit: TemperatureUnit = (
converter_field(
default=TemperatureUnit.CELSIUS,
converter=TemperatureUnit.convert,
)
or TemperatureUnit.CELSIUS
temperature_unit: TemperatureUnit = converter_field(
default=TemperatureUnit.CELSIUS,
converter=TemperatureUnit.convert,

Copilot uses AI. Check for mistakes.
@jhenkens
Copy link
Copy Markdown
Contributor Author

jhenkens commented Apr 22, 2026

@bdraco Sorry for the thrash. Closing this PR in favor of #1600 due to main GHA bumping version.

Only change is addressing the Copilot feedback.

@jhenkens jhenkens closed this Apr 22, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 23, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants