Просмотр исходного кода

feat(devices): add EZAIoT Lite socket thermostat (#5358)

Rebuilt after your review. The translations are gone, and so is the
rework on
`ezaiot_thermostat_smartplug.yaml`: this is one new config plus one line
in
DEVICES.md, +484/-0 instead of +1954/-31 across 25 files.

### Your review points

- `fan_only` → heating: dropped, the device has no fan mode
- countdown: now a `number` with the existing `timer` key
- fault: `fault_code` attribute added
- frost protection: existing `anti_frost` key
- cooling delay and its time: plain names, no new keys
- alarms: `name: High temperature alarm` on both the switch and the
number, words spelled out
- hysteresis: `name: Temperature hysteresis`
- display: `light` with `translation_key: display`, mapped to 85/170/255
- translations: removed, only existing keys are used

Three I did not follow. Happy to change any of them:

- **The socket switch keeps a `translation_key`.** You objected to the
invented
`manual_socket`; this uses `outlet_x`, which is already translated in
all 23
languages. Left unnamed it inherits the device name and sits unlabelled
next to the
  anti-frost switch.
- **The mode select stays.** Your "just duplicating the hvac_mode" was
right as it
stood — both were on dp 110. `hvac_mode` is now on dp 45, which is the
actual system
power, and the select is the only route to `cycle` and `plug`, neither
of which has
  an hvac_mode that fits.
- **The relay binary_sensor stays.** `hvac_action` reports `off` in prg,
cycle and plug
mode, where the relay still switches, so this is the only indication
that the socket
  is live.

### What the rebuild changed

dp 45 is the system power and was missing entirely, so `hvac_mode` sat
on dp 110 and
conflated power with mode. Added since: the cycle timer (dp 109, two big
endian 32 bit
seconds in one base64 value), the program sub-mode (dp 108) and the
temperature unit
(dp 125, which the device reports in lower case). `hvac_action` fell
through to the raw
`1` in three modes. `range` and `step` are raw rather than scaled, which
had capped all
three time fields at 24 minutes with one-second steps.

dp 109 needs `force: true`: the device leaves every base64 dp out of its
status reports
— 101-107 and 109 were all absent over a minute of polling — so both
cycle numbers stay
unknown without an explicit request.

`duplicates` reports no matches.

One note while I was in there, not touched: in `device.py`
`register_entity` compares
`dp.id` (a string) against `self._force_dps` (ints), so the
de-duplication never
matches and a dp forced by two entities is requested twice.

### Disclosure

Written with AI assistance (Claude). Every datapoint above was measured
on my own
device, the modes and ranges checked against the vendor app, and I read
every line.
Happy to split this, drop any part, or take a different approach.

---------

Co-authored-by: brickronny <brickronny@pixelounge.de>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jason Rumney <make-all@users.noreply.github.com>
brickronny 1 день назад
Родитель
Сommit
95300d05ac
2 измененных файлов с 479 добавлено и 2 удалено
  1. 1 2
      DEVICES.md
  2. 478 0
      custom_components/tuya_local/devices/ezaiot_lite_socket_thermostat.yaml

+ 1 - 2
DEVICES.md

@@ -313,8 +313,7 @@
 - Ettroit LN4102 thermostat
 - Eurom WiFi thermostat
 - Euroster 4040 Smart thermostat
-- EZAIoT R9Lite thermostat
-- EZAIoT thermostat smartplug
+- EZAIoT thermostat smartplugs (including Lite and R9Lite models)
 - Fercofloor FT2C10V fan coil thermostat
 - Garza Aspen and (unknown model) thermostats
 - HaoLeSi Mini thermostat

+ 478 - 0
custom_components/tuya_local/devices/ezaiot_lite_socket_thermostat.yaml

@@ -0,0 +1,478 @@
+# Unmodelled dps: 101-107 hold the weekly schedule of prg mode, one base64
+# blob per weekday - a day index, then six periods of hour, minute and a big
+# endian 16 bit temperature in tenths. That is 84 values with no schedule
+# entity type to put them in, so prg mode is selectable here but edited in
+# the vendor app. 116 is a boolean of unknown purpose, 119-124 are counters,
+# a build date and an opaque token.
+name: Socket thermostat
+products:
+  - id: lzfux5zela3cbpnd
+    manufacturer: ezAIoT
+    model: Lite
+entities:
+  # A socket with an external temperature probe. dp 45 is the system power,
+  # independent of dp 110, which picks one of five modes: heat, cool, prg (a
+  # weekly schedule), cycle (a timed on/off cycle) and plug (a plain socket).
+  - entity: climate
+    translation_key: thermostat
+    dps:
+      - id: 45
+        type: boolean
+        name: hvac_mode
+        mapping:
+          - dps_val: false
+            value: "off"
+          - dps_val: true
+            constraint: mode
+            conditions:
+              - dps_val: heat
+                value: heat
+              - dps_val: cool
+                value: cool
+              # prg, cycle and plug report off: none regulate to the setpoint
+              # this entity holds. prg follows the schedule in dp 101-107, and
+              # dp 108 decides whether it heats or cools.
+              - dps_val:
+                  - prg
+                  - cycle
+                  - plug
+                value: "off"
+                hidden: true
+      - id: 110
+        type: string
+        name: mode
+        hidden: true
+      - id: 114
+        type: integer
+        name: hvac_action
+        mapping:
+          - dps_val: 0
+            value: idle
+          - dps_val: 1
+            constraint: mode
+            conditions:
+              - dps_val: heat
+                value: heating
+              - dps_val: cool
+                value: cooling
+              - dps_val:
+                  - prg
+                  - cycle
+                  - plug
+                value: "off"
+      - id: 125
+        type: string
+        name: temperature_unit
+        mapping:
+          - dps_val: c
+            value: C
+          - dps_val: f
+            value: F
+      - id: 126
+        type: integer
+        name: current_temperature
+        mapping:
+          - scale: 10
+      - id: 135
+        type: integer
+        name: temperature
+        unit: C
+        mapping:
+          - scale: 10
+            step: 5
+        range:
+          min: -500
+          max: 3000
+      - id: 127
+        type: integer
+        name: max_temperature
+        mapping:
+          - scale: 10
+      - id: 130
+        type: integer
+        name: min_temperature
+        mapping:
+          - scale: 10
+
+  # Built on dp 45 like the climate entity, so "Off" writes dp 45 alone while
+  # any mode writes dp 110 and dp 45 together. It also reaches cycle and plug,
+  # which have no fitting hvac_mode, so there is no separate master switch.
+  - entity: select
+    translation_key: mode
+    dps:
+      - id: 45
+        type: boolean
+        name: option
+        mapping:
+          - dps_val: false
+            value: "Off"
+          - dps_val: true
+            constraint: mode
+            conditions:
+              - dps_val: heat
+                value: Heat
+              - dps_val: cool
+                value: Cool
+              - dps_val: prg
+                value: Program
+              - dps_val: cycle
+                value: Cycle timer
+              - dps_val: plug
+                value: Socket
+      - id: 110
+        type: string
+        name: mode
+        hidden: true
+
+  # Only meaningful in plug mode, where the socket is switched directly.
+  - entity: switch
+    class: outlet
+    translation_key: outlet_x
+    translation_placeholders:
+      x: "1"
+    dps:
+      - id: 1
+        type: boolean
+        name: switch
+      # Plug mode and system on: dp 1 keeps reporting on with the system off.
+      # Only one dp can drive availability, so the mode rides along as a
+      # constraint, which again has no catch-all.
+      - id: 45
+        type: boolean
+        name: available
+        mapping:
+          - dps_val: false
+            value: false
+          - dps_val: true
+            constraint: mode
+            conditions:
+              - dps_val: plug
+                value: true
+              - dps_val:
+                  - heat
+                  - cool
+                  - prg
+                  - cycle
+                value: false
+      - id: 110
+        type: string
+        name: mode
+        hidden: true
+
+  # Switch-off delay for the socket, in plug mode.
+  - entity: number
+    translation_key: timer
+    category: config
+    dps:
+      - id: 9
+        type: integer
+        name: value
+        optional: true
+        unit: min
+        # Seconds on the wire. The app stops at 60 minutes, but the dp takes
+        # any value in between - 420 and 1380 were accepted unchanged. 0 is
+        # off, which the app presents as a separate toggle.
+        mapping:
+          - scale: 60
+            step: 60
+        range:
+          min: 0
+          max: 3600
+      - id: 110
+        type: string
+        name: available
+        mapping:
+          - dps_val: plug
+            value: true
+          - value: false
+
+  # dp 109 packs the cycle timer into eight bytes: two big endian 32 bit
+  # values in seconds, on time then off time. The app enters both as hours
+  # 0-9 plus minutes 0-59, hence the ceiling. force is needed because the
+  # device leaves every base64 dp out of its status reports; one request on
+  # the first entity covers both.
+  - entity: number
+    name: Cycle on time
+    category: config
+    icon: "mdi:timer-play"
+    dps:
+      - id: 109
+        type: base64
+        name: value
+        optional: true
+        force: true
+        mask: "FFFFFFFF00000000"
+        unit: min
+        mapping:
+          - scale: 60
+            step: 60
+        range:
+          min: 0
+          max: 35940
+      - id: 110
+        type: string
+        name: available
+        mapping:
+          - dps_val: cycle
+            value: true
+          - value: false
+
+  - entity: number
+    name: Cycle off time
+    category: config
+    icon: "mdi:timer-pause"
+    dps:
+      - id: 109
+        type: base64
+        name: value
+        optional: true
+        mask: "00000000FFFFFFFF"
+        unit: min
+        mapping:
+          - scale: 60
+            step: 60
+        range:
+          min: 0
+          max: 35940
+      - id: 110
+        type: string
+        name: available
+        mapping:
+          - dps_val: cycle
+            value: true
+          - value: false
+
+  # Configures prg and nothing else. heat_pump_mode is borrowed for its
+  # translated states, hence the explicit name: this is not a heat pump, but
+  # the key already renders heat and cool in every language.
+  - entity: select
+    name: Program mode
+    translation_key: heat_pump_mode
+    category: config
+    icon: "mdi:calendar-clock"
+    dps:
+      - id: 108
+        type: string
+        name: option
+        optional: true
+        mapping:
+          - dps_val: heat
+            value: heat
+          - dps_val: cool
+            value: cool
+      - id: 110
+        type: string
+        name: available
+        mapping:
+          - dps_val: prg
+            value: true
+          - value: false
+
+  # Not redundant with hvac_action: that reports off in prg, cycle and plug
+  # mode, where the relay still switches.
+  - entity: binary_sensor
+    class: power
+    category: diagnostic
+    dps:
+      - id: 114
+        type: integer
+        name: sensor
+        mapping:
+          - dps_val: 0
+            value: false
+          - dps_val: 1
+            value: true
+
+  - entity: binary_sensor
+    class: problem
+    category: diagnostic
+    dps:
+      - id: 26
+        type: bitfield
+        name: sensor
+        mapping:
+          - dps_val: 0
+            value: false
+          - value: true
+      - id: 26
+        type: bitfield
+        name: fault_code
+
+  - entity: lock
+    translation_key: child_lock
+    category: config
+    dps:
+      - id: 40
+        type: boolean
+        name: lock
+
+  - entity: switch
+    translation_key: anti_frost
+    category: config
+    dps:
+      - id: 113
+        type: boolean
+        name: switch
+
+  - entity: switch
+    name: Cooling delay
+    category: config
+    icon: "mdi:timer-sand"
+    dps:
+      - id: 133
+        type: boolean
+        name: switch
+        optional: true
+
+  - entity: number
+    name: Cooling delay time
+    category: config
+    icon: "mdi:timer-sand"
+    dps:
+      - id: 134
+        type: integer
+        name: value
+        optional: true
+        unit: min
+        range:
+          min: 0
+          max: 10
+
+  - entity: switch
+    name: High temperature alarm
+    category: config
+    icon: "mdi:thermometer-chevron-up"
+    dps:
+      - id: 128
+        type: boolean
+        name: switch
+        optional: true
+
+  - entity: number
+    name: High temperature alarm
+    class: temperature
+    category: config
+    icon: "mdi:thermometer-alert"
+    dps:
+      - id: 129
+        type: integer
+        name: value
+        optional: true
+        unit: C
+        mapping:
+          - scale: 10
+            step: 10
+        range:
+          min: -500
+          max: 3000
+
+  - entity: switch
+    name: Low temperature alarm
+    category: config
+    icon: "mdi:thermometer-chevron-down"
+    dps:
+      - id: 131
+        type: boolean
+        name: switch
+        optional: true
+
+  - entity: number
+    name: Low temperature alarm
+    class: temperature
+    category: config
+    icon: "mdi:thermometer-alert"
+    dps:
+      - id: 132
+        type: integer
+        name: value
+        optional: true
+        unit: C
+        mapping:
+          - scale: 10
+            step: 10
+        range:
+          min: -500
+          max: 3000
+
+  - entity: number
+    name: Temperature hysteresis
+    class: temperature_delta
+    category: config
+    mode: "box"
+    dps:
+      - id: 111
+        type: integer
+        name: value
+        optional: true
+        unit: C
+        mapping:
+          - scale: 10
+        range:
+          min: 1
+          max: 300
+
+  - entity: number
+    translation_key: temperature_calibration
+    class: temperature_delta
+    category: config
+    dps:
+      - id: 136
+        type: integer
+        name: value
+        optional: true
+        unit: C
+        mapping:
+          - scale: 10
+        range:
+          min: -150
+          max: 150
+
+  - entity: light
+    translation_key: display
+    category: config
+    dps:
+      - id: 115
+        type: string
+        name: brightness
+        optional: true
+        mapping:
+          - dps_val: Low
+            value: 85
+          - dps_val: Middle
+            value: 170
+          - dps_val: High
+            value: 255
+
+  # The generic timer key is borrowed for its translated durations; the
+  # device's "never" maps onto continuous.
+  - entity: select
+    name: Display timeout
+    translation_key: timer
+    category: config
+    icon: "mdi:timer-outline"
+    dps:
+      - id: 117
+        type: string
+        name: option
+        optional: true
+        mapping:
+          - dps_val: 15s
+            value: 15s
+          - dps_val: 30s
+            value: 30s
+          - dps_val: 60s
+            value: 1m
+          - dps_val: 120s
+            value: 2m
+          - dps_val: never
+            value: continuous
+
+  - entity: button
+    name: Display on
+    category: config
+    icon: "mdi:monitor"
+    dps:
+      - id: 118
+        type: boolean
+        name: button
+        optional: true