diff --git a/README.md b/README.md index fd5908c..2f4d5c0 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,285 @@ -# M4 DINPLUG – Home Assistant Integration (YAML) +# DINPLUG - Home Assistant Integration (YAML) -Integração customizada do Home Assistant para controlar módulos de iluminação **M4 / DINPLUG** via Telnet (porta 23). +This custom integration for Home Assistant allows you to control **DINPLUG** modules via Telnet (port 23). -Esta integração permite controlar cargas individuais dos módulos M4 como entidades `light`, incluindo: -- Luzes On/Off -- Dimmers (0–100%) -- Múltiplos módulos e múltiplos canais -- Atualizações em tempo real por telemetria `R:LOAD` +It supports the following platforms: +- `light`: For On/Off lights and dimmers. +- `cover`: For shades and blinds. +- `climate`: For HVAC systems. +- `sensor`: To monitor keypad button presses. -> 📌 Esta é a versão baseada em **YAML** (sem Config Flow). -> -> Ideal para instalações profissionais, simples e diretas. +> 📌 This is the **YAML-based** version (no Config Flow). Ideal for simple, direct, and professional installations. --- -## 📦 Instalação - -1. Baixe este repositório. -2. Copie a pasta: - - - -custom_components/dinplug +## ⚠️ Breaking Change (Version 0.2.0) +The YAML configuration format has been updated to support multiple platforms under a single host. If you are upgrading from a previous version, you **must** update your `configuration.yaml` file. -para dentro do diretório de configuração do Home Assistant: - - - -/config/custom_components/dinplug +**Old format:** +```yaml +light: + - platform: dinplug + host: 192.168.51.30 + # ... +``` +**New format:** +```yaml +dinplug: + - host: 192.168.51.30 + lights: + # ... + shades: + # ... +``` -A estrutura final deve ficar assim: +--- +## 📦 Installation +1. Download this repository. +2. Copy the folder `custom_components/dinplug` into your Home Assistant configuration directory (`/config`). +3. Restart Home Assistant. +The final structure should look like this: +``` /config └── custom_components -└── dinplug -├── init.py -├── const.py -├── light.py -└── manifest.json - - -3. Reinicie o Home Assistant. + └── dinplug + ├── __init__.py + ├── hub.py + ├── light.py + ├── cover.py + ├── climate.py + ├── sensor.py + └── manifest.json +``` --- -## ⚙️ Configuração via YAML +## ⚙️ Configuration (YAML) -Adicione ao `configuration.yaml`: +Add the `dinplug` integration to your `configuration.yaml` file. All platforms (light, cover, climate, sensor) are configured under the same host. ```yaml -light: - - platform: dinplug - host: 192.168.51.30 +dinplug: + - host: 192.168.51.30 port: 23 lights: - - name: "Sala Teto" + - name: "Living Room Ceiling" device: 104 channel: 1 dimmer: true - - - name: "Cozinha Spots" - device: 107 - channel: 4 - dimmer: false + shades: + - name: "Living Room Shade" + device: 201 + channel: 1 + hvacs: + - name: "Main HVAC" + device: 301 + buttons: + - name: "Keypad Button 1" + device: 401 + channel: 1 ``` -### Campos disponíveis -| Campo | Tipo | Obrigatório | Descrição | -| --- | --- | --- | --- | -| `host` | string | ✔ Sim | IP do controlador DINPLUG | -| `port` | número | ✖ Não (23) | Porta Telnet | -| `lights` | lista | ✔ Sim | Lista de cargas | -| `device` | número | ✔ Sim | Endereço do módulo (ex: 104) | -| `channel` | número | ✔ Sim | Canal do módulo (1–n) | -| `name` | string | ✔ Sim | Nome da entidade no HA | -| `dimmer` | booleano | ✖ Não (true) | `TRUE` = dimmer, `FALSE` = on/off | -### 💡 Como funciona +### Light Configuration +| Field | Type | Required | Description | +|-----------|---------|---------------|--------------------------------------| +| `host` | string | ✔ Yes | IP address of the DINPLUG controller | +| `port` | number | ✖ No (23) | Telnet port | +| `lights` | list | ✔ Yes | List of loads | +| `device` | number | ✔ Yes | Module address (e.g., 104) | +| `channel` | number | ✔ Yes | Module channel (1–n) | +| `name` | string | ✔ Yes | Entity name in Home Assistant | +| `dimmer` | boolean | ✖ No (true) | `true` = dimmer, `false` = on/off | + +### Cover (Shade) Configuration +| Field | Type | Required | Description | +|-----------|---------|---------------|--------------------------------------| +| `shades` | list | ✔ Yes | List of shades | +| `device` | number | ✔ Yes | Module address | +| `channel` | number | ✔ Yes | Module channel | +| `name` | string | ✔ Yes | Entity name in Home Assistant | + +### Climate (HVAC) Configuration +| Field | Type | Required | Description | +|-----------|---------|---------------|--------------------------------------| +| `hvacs` | list | ✔ Yes | List of HVAC units | +| `device` | number | ✔ Yes | Module address | +| `name` | string | ✔ Yes | Entity name in Home Assistant | + +### Sensor (Button) Configuration +| Field | Type | Required | Description | +|-----------|---------|---------------|--------------------------------------| +| `buttons` | list | ✔ Yes | List of keypad buttons to monitor | +| `device` | number | ✔ Yes | Keypad address | +| `channel` | number | ✔ Yes | Button number | +| `name` | string | ✔ Yes | Entity name in Home Assistant | -O Home Assistant abre uma conexão TCP com o controlador M4 e: +--- -**Envia comandos:** +### ✔️ Supported Features -`LOAD ` +- [x] Light: ON/OFF and dimmer control +- [x] Cover: Open, close, stop, and set position +- [x] Climate: Mode, temperature, and fan control +- [x] Sensor: Real-time button state (`press`, `release`, `hold`, `double`) +- [x] Instant status updates via telemetry +- [x] No polling -- `level = 0` → OFF -- `level = 1–100` → dimmer -- `level = 100` → ON +--- -**Recebe telemetria:** +### 🐞 Debugging -`R:LOAD ` -Atualiza o estado instantaneamente no HA. +To enable detailed logs, add this to `configuration.yaml`: +```yaml +logger: + default: warning + logs: + custom_components.dinplug: debug +``` -**Mantém conexão viva:** +--- +--- -- Envia `STA` periodicamente -- Monitora `R:MODULE STATUS` para disponibilidade +# DINPLUG – Integração Home Assistant (YAML) -Tudo é push-based — sem polling. +Integração customizada do Home Assistant para controlar módulos **DINPLUG** via Telnet (porta 23). -### 🔌 Exemplo completo +Suporta as seguintes plataformas: +- `light`: Luzes On/Off e dimmers. +- `cover`: Cortinas e persianas. +- `climate`: Sistemas de ar condicionado (HVAC). +- `sensor`: Monitoramento de botões de keypads. + +> 📌 Esta é a versão baseada em **YAML** (sem Config Flow). Ideal para instalações profissionais, simples e diretas. + +--- + +## ⚠️ Breaking Change (Versão 0.2.0) + +O formato de configuração YAML foi atualizado para suportar múltiplas plataformas sob um único host. Se você está atualizando de uma versão anterior, **precisa** atualizar seu arquivo `configuration.yaml`. + +**Formato antigo:** ```yaml light: - platform: dinplug host: 192.168.51.30 + # ... +``` + +**Novo formato:** +```yaml +dinplug: + - host: 192.168.51.30 + lights: + # ... + shades: + # ... +``` + +--- + +## 📦 Instalação + +1. Baixe este repositório. +2. Copie a pasta `custom_components/dinplug` para o diretório de configuração do seu Home Assistant (`/config`). +3. Reinicie o Home Assistant. + +A estrutura final deve ficar assim: +``` +/config +└── custom_components + └── dinplug + ├── __init__.py + ├── hub.py + ├── light.py + ├── cover.py + ├── climate.py + ├── sensor.py + └── manifest.json +``` + +--- + +## ⚙️ Configuração via YAML + +Adicione a integração `dinplug` ao seu arquivo `configuration.yaml`. Todas as plataformas (light, cover, climate, sensor) são configuradas sob o mesmo host. + +```yaml +dinplug: + - host: 192.168.51.30 port: 23 lights: - name: "Sala Teto" device: 104 channel: 1 dimmer: true + shades: + - name: "Cortina Sala" + device: 201 + channel: 1 + hvacs: + - name: "AC Principal" + device: 301 + buttons: + - name: "Botão Keypad 1" + device: 401 + channel: 1 +``` - - name: "Sala Arandela" - device: 104 - channel: 2 - dimmer: false - - - name: "Spots Cozinha" - device: 107 - channel: 4 - dimmer: true +### Configuração de Luzes (Light) +| Campo | Tipo | Obrigatório | Descrição | +|-----------|----------|---------------|-------------------------------------| +| `host` | string | ✔ Sim | IP do controlador DINPLUG | +| `port` | número | ✖ Não (23) | Porta Telnet | +| `lights` | lista | ✔ Sim | Lista de cargas | +| `device` | número | ✔ Sim | Endereço do módulo (ex: 104) | +| `channel` | número | ✔ Sim | Canal do módulo (1–n) | +| `name` | string | ✔ Sim | Nome da entidade no HA | +| `dimmer` | booleano | ✖ Não (true) | `true` = dimmer, `false` = on/off | + +### Configuração de Cortinas (Cover) +| Campo | Tipo | Obrigatório | Descrição | +|-----------|----------|---------------|-------------------------------------| +| `shades` | lista | ✔ Sim | Lista de cortinas | +| `device` | número | ✔ Sim | Endereço do módulo | +| `channel` | número | ✔ Sim | Canal do módulo | +| `name` | string | ✔ Sim | Nome da entidade no HA | + +### Configuração de Ar Condicionado (Climate) +| Campo | Tipo | Obrigatório | Descrição | +|-----------|----------|---------------|-------------------------------------| +| `hvacs` | lista | ✔ Sim | Lista de equipamentos de AC | +| `device` | número | ✔ Sim | Endereço do módulo | +| `name` | string | ✔ Sim | Nome da entidade no HA | + +### Configuração de Sensores (Button) +| Campo | Tipo | Obrigatório | Descrição | +|-----------|----------|---------------|-------------------------------------| +| `buttons` | lista | ✔ Sim | Lista de botões de keypad | +| `device` | número | ✔ Sim | Endereço do keypad | +| `channel` | número | ✔ Sim | Número do botão | +| `name` | string | ✔ Sim | Nome da entidade no HA | - - name: "Corredor" - device: 105 - channel: 3 - dimmer: false -``` --- -### ✔️ Recursos suportados +### ✔️ Recursos Suportados -- [x] Controle ON/OFF -- [x] Controle de dimmer (brightness) -- [x] Atualização instantânea por telemetria +- [x] Light: Controle ON/OFF e dimmer +- [x] Cover: Abrir, fechar, parar e definir posição +- [x] Climate: Controle de modo, temperatura e ventilação +- [x] Sensor: Estado do botão em tempo real (`press`, `release`, `hold`, `double`) +- [x] Atualização de status instantânea por telemetria - [x] Sem polling -- [x] Múltiplos módulos e canais -- [x] Disponibilidade online/offline por módulo - -### 🚧 Roadmap (próximas versões) - -- [ ] Auto-descoberta de loads via `REFRESH` -- [ ] Configuração via UI (Config Flow) -- [ ] Suporte a Scenes (`SCN`) -- [ ] Suporte a Cortinas (`SHADE`) -- [ ] Suporte a HVAC -- [ ] Criação automática de `Devices` por módulo --- -### 🐞 Debug (opcional) - -Para ativar logs detalhados da integração: +### 🐞 Debug +Para ativar logs detalhados, adicione ao `configuration.yaml`: ```yaml logger: default: warning diff --git a/csv-to-yaml.exe b/csv-to-yaml.exe deleted file mode 100644 index 17c471c..0000000 Binary files a/csv-to-yaml.exe and /dev/null differ diff --git a/csv-to-yaml.py b/csv-to-yaml.py deleted file mode 100644 index 6a852b7..0000000 --- a/csv-to-yaml.py +++ /dev/null @@ -1,209 +0,0 @@ -import tkinter as tk -from tkinter import ttk, filedialog, messagebox -import csv -import yaml -import os - -class CSVToYAMLConverter: - def __init__(self, root): - self.root = root - self.root.title("CSV to YAML Converter") - self.root.geometry("650x650") - - self.csv_file_path = tk.StringVar() - self.host_ip = tk.StringVar(value="192.168.5.30") # Default IP - - self.create_widgets() - - def create_widgets(self): - # Main frame - main_frame = ttk.Frame(self.root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - - # Title - title_label = ttk.Label(main_frame, text="CSV to YAML Converter", - font=("Arial", 16, "bold")) - title_label.grid(row=0, column=0, columnspan=3, pady=(0, 20)) - - # File selection section - file_frame = ttk.LabelFrame(main_frame, text="CSV File Selection", padding="10") - file_frame.grid(row=1, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10)) - - ttk.Label(file_frame, text="Select CSV File:").grid(row=0, column=0, sticky=tk.W) - - ttk.Entry(file_frame, textvariable=self.csv_file_path, width=50).grid( - row=1, column=0, sticky=(tk.W, tk.E), padx=(0, 10)) - - ttk.Button(file_frame, text="Browse", - command=self.browse_csv_file).grid(row=1, column=1) - - # Host IP section - ip_frame = ttk.LabelFrame(main_frame, text="Connection Settings", padding="10") - ip_frame.grid(row=2, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10)) - - ttk.Label(ip_frame, text="Host IP:").grid(row=0, column=0, sticky=tk.W) - ttk.Entry(ip_frame, textvariable=self.host_ip, width=20).grid( - row=0, column=1, sticky=tk.W, padx=(10, 0)) - - # Convert button - ttk.Button(main_frame, text="Convert to YAML", - command=self.convert_to_yaml).grid(row=3, column=0, columnspan=3, pady=20) - - # Results section - results_frame = ttk.LabelFrame(main_frame, text="Conversion Results", padding="10") - results_frame.grid(row=4, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 10)) - - # Text widget for displaying YAML - self.yaml_text = tk.Text(results_frame, height=15, width=70) - yaml_scrollbar = ttk.Scrollbar(results_frame, orient="vertical", command=self.yaml_text.yview) - self.yaml_text.configure(yscrollcommand=yaml_scrollbar.set) - - self.yaml_text.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - yaml_scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S)) - - # Button frame for Save and Copy buttons - button_frame = ttk.Frame(main_frame) - button_frame.grid(row=5, column=0, columnspan=3, pady=10) - - # Save button - self.save_button = ttk.Button(button_frame, text="Save YAML File", - command=self.save_yaml_file, state="disabled") - self.save_button.grid(row=0, column=0, padx=(0, 10)) - - # Copy button - self.copy_button = ttk.Button(button_frame, text="Copy YAML", - command=self.copy_yaml, state="disabled") - self.copy_button.grid(row=0, column=1) - - # Configure grid weights - main_frame.columnconfigure(0, weight=1) - main_frame.rowconfigure(4, weight=1) - file_frame.columnconfigure(0, weight=1) - results_frame.columnconfigure(0, weight=1) - results_frame.rowconfigure(0, weight=1) - - def browse_csv_file(self): - file_path = filedialog.askopenfilename( - title="Select CSV File", - filetypes=[("CSV files", "*.csv"), ("All files", "*.*")] - ) - if file_path: - self.csv_file_path.set(file_path) - - def convert_to_yaml(self): - if not self.csv_file_path.get(): - messagebox.showerror("Error", "Please select a CSV file first.") - return - - if not self.host_ip.get(): - messagebox.showerror("Error", "Please enter a host IP address.") - return - - try: - with open(self.csv_file_path.get(), 'r', encoding='utf-8') as csv_file: - csv_reader = csv.DictReader(csv_file) - lights_list = [] - - for row in csv_reader: - # Skip Keypad Button and Button entries - if row['Entity'] not in ['Switch', 'Dimmer']: - continue - - # Extract device and channel from Address (format: "107:8") - address_parts = row['Address'].split(':') - - # Convert to integers to avoid quotes in YAML - try: - device = int(address_parts[0]) - channel = int(address_parts[1]) - except ValueError: - # If conversion fails, keep as string but show warning - device = address_parts[0] - channel = address_parts[1] - - # Determine dimmer value - dimmer = row['Button Type'] == 'Dimmer' - - light_config = { - 'name': row['Label'], - 'device': device, - 'channel': channel, - 'dimmer': dimmer - } - - lights_list.append(light_config) - - # Create the final YAML structure - yaml_structure = { - 'light': [{ - 'platform': 'dinplug', - 'host': self.host_ip.get(), - 'port': 23, - 'lights': lights_list - }] - } - - yaml_output = yaml.dump(yaml_structure, default_flow_style=False, - allow_unicode=True, sort_keys=False) - - # Display YAML in text widget - self.yaml_text.delete(1.0, tk.END) - self.yaml_text.insert(1.0, yaml_output) - - # Enable save and copy buttons - self.save_button.config(state="normal") - self.copy_button.config(state="normal") - - messagebox.showinfo("Success", f"Converted {len(lights_list)} lights to YAML format!") - - except Exception as e: - messagebox.showerror("Error", f"Failed to convert CSV file:\n{str(e)}") - - def save_yaml_file(self): - if not self.yaml_text.get(1.0, tk.END).strip(): - messagebox.showerror("Error", "No YAML content to save.") - return - - # Suggest a filename based on the CSV file - csv_path = self.csv_file_path.get() - if csv_path: - base_name = os.path.splitext(csv_path)[0] - suggested_name = base_name + ".yaml" - else: - suggested_name = "lights_config.yaml" - - file_path = filedialog.asksaveasfilename( - title="Save YAML File", - initialfile=os.path.basename(suggested_name), - filetypes=[("YAML files", "*.yaml"), ("All files", "*.*")] - ) - - if file_path: - try: - with open(file_path, 'w', encoding='utf-8') as yaml_file: - yaml_file.write(self.yaml_text.get(1.0, tk.END)) - messagebox.showinfo("Success", f"YAML file saved successfully!\n{file_path}") - except Exception as e: - messagebox.showerror("Error", f"Failed to save YAML file:\n{str(e)}") - - def copy_yaml(self): - yaml_content = self.yaml_text.get(1.0, tk.END).strip() - if not yaml_content: - messagebox.showwarning("Warning", "No YAML content to copy.") - return - - try: - self.root.clipboard_clear() - self.root.clipboard_append(yaml_content) - self.root.update() # Keep the clipboard content after the program exits - messagebox.showinfo("Success", "YAML content copied to clipboard!") - except Exception as e: - messagebox.showerror("Error", f"Failed to copy to clipboard:\n{str(e)}") - -def main(): - root = tk.Tk() - app = CSVToYAMLConverter(root) - root.mainloop() - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/custom_components/dinplug/__init__.py b/custom_components/dinplug/__init__.py index 966b689..d249257 100644 --- a/custom_components/dinplug/__init__.py +++ b/custom_components/dinplug/__init__.py @@ -1,10 +1,37 @@ import logging +import voluptuous as vol +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.const import CONF_HOST +import homeassistant.helpers.config_validation as cv from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +PLATFORMS = ["light", "climate", "cover", "sensor"] + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All(cv.ensure_list, [dict]) + }, + extra=vol.ALLOW_EXTRA, +) + async def async_setup(hass, config): - """Set up via YAML (platforms will handle connection creation).""" + """Set up the dinplug integration from YAML.""" hass.data.setdefault(DOMAIN, {}) + + if DOMAIN not in config: + return True + + for host_config in config[DOMAIN]: + host = host_config[CONF_HOST] + _LOGGER.debug("Setting up dinplug host: %s", host) + + for p in PLATFORMS: + if p in host_config: + hass.async_create_task( + async_load_platform(hass, p, DOMAIN, host_config, config) + ) + return True diff --git a/custom_components/dinplug/climate.py b/custom_components/dinplug/climate.py new file mode 100644 index 0000000..5709b62 --- /dev/null +++ b/custom_components/dinplug/climate.py @@ -0,0 +1,138 @@ +import logging +from typing import Any, Dict, Optional, List + +import voluptuous as vol + +from homeassistant.components.climate import ( + PLATFORM_SCHEMA, + ClimateEntity, + HVACAction, + HVACMode, + ClimateEntityFeature, +) +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_NAME, UnitOfTemperature +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, CONF_DEVICE +from .hub import M4Connection + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PORT = 23 + +CONF_CLIMATES = "hvacs" + +CLIMATE_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_DEVICE): vol.Coerce(int), + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_CLIMATES): vol.All(cv.ensure_list, [CLIMATE_SCHEMA]), + } +) + +HA_TO_M4_HVAC_MODE = { + HVACMode.OFF: "OFF", + HVACMode.COOL: "COOL", + HVACMode.HEAT: "HEAT", + HVACMode.FAN_ONLY: "FAN", +} +M4_TO_HA_HVAC_MODE = {v: k for k, v in HA_TO_M4_HVAC_MODE.items()} + +HA_TO_M4_FAN_MODE = {"low": "LOW", "medium": "MID", "high": "HIGH"} +M4_TO_HA_FAN_MODE = {v: k for k, v in HA_TO_M4_FAN_MODE.items()} + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up dinplug climates from YAML.""" + host = config[CONF_HOST] + port = config[CONF_PORT] + climates_conf = config[CONF_CLIMATES] + + conn = hass.data[DOMAIN].get((host, port)) + if not conn: + conn = M4Connection(hass, host, port) + hass.data[DOMAIN][(host, port)] = conn + conn.start() + + entities = [ + M4Climate(conn, host, port, cfg[CONF_NAME], cfg[CONF_DEVICE]) + for cfg in climates_conf + ] + async_add_entities(entities, update_before_add=True) + + +class M4Climate(ClimateEntity): + _attr_should_poll = False + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT, HVACMode.FAN_ONLY] + _attr_fan_modes = ["low", "medium", "high"] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + + def __init__(self, conn: M4Connection, host: str, port: int, name: str, device: int): + self._conn = conn + self._host = host + self._port = port + self._attr_name = name + self._device = device + self._attr_unique_id = f"{host}-{port}-{device}-hvac" + + self._state: Dict[str, Any] = {} + + self._conn.register_hvac_listener(self._device, self._handle_update) + + last_state = self._conn.get_last_hvac_state(self._device) + if last_state: + self._handle_update(last_state) + + def _handle_update(self, state: Dict[str, Any]): + self._state = state + self.schedule_update_ha_state() + + @property + def current_temperature(self) -> Optional[float]: + return self._state.get("current_temperature") + + @property + def target_temperature(self) -> Optional[float]: + return self._state.get("temperature") + + @property + def hvac_mode(self) -> Optional[HVACMode]: + mode = self._state.get("mode") + return M4_TO_HA_HVAC_MODE.get(mode.upper()) if mode else None + + @property + def fan_mode(self) -> Optional[str]: + mode = self._state.get("fan_mode") + return M4_TO_HA_FAN_MODE.get(mode.upper()) if mode else None + + @property + def hvac_action(self) -> Optional[HVACAction]: + if self.hvac_mode == HVACMode.COOL: + return HVACAction.COOLING + if self.hvac_mode == HVACMode.HEAT: + return HVACAction.HEATING + if self.hvac_mode == HVACMode.FAN_ONLY: + return HVACAction.FAN + return HVACAction.OFF + + async def async_set_temperature(self, **kwargs): + if temp := kwargs.get("temperature"): + self._conn.send_hvac(self._device, "SETPOINT", temp) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode): + if m4_mode := HA_TO_M4_HVAC_MODE.get(hvac_mode): + self._conn.send_hvac(self._device, m4_mode) + + async def async_set_fan_mode(self, fan_mode: str): + if m4_mode := HA_TO_M4_FAN_MODE.get(fan_mode): + self._conn.send_hvac(self._device, f"FAN{m4_mode}") diff --git a/custom_components/dinplug/cover.py b/custom_components/dinplug/cover.py new file mode 100644 index 0000000..371cfa0 --- /dev/null +++ b/custom_components/dinplug/cover.py @@ -0,0 +1,128 @@ +import logging +from typing import Optional + +import voluptuous as vol + +from homeassistant.components.cover import ( + PLATFORM_SCHEMA, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_NAME +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, CONF_DEVICE, CONF_CHANNEL +from .hub import M4Connection + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PORT = 23 + +CONF_COVERS = "shades" + +COVER_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_DEVICE): vol.Coerce(int), + vol.Required(CONF_CHANNEL): vol.Coerce(int), + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_COVERS): vol.All(cv.ensure_list, [COVER_SCHEMA]), + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up dinplug covers from YAML.""" + host = config[CONF_HOST] + port = config[CONF_PORT] + covers_conf = config[CONF_COVERS] + + conn = hass.data[DOMAIN].get((host, port)) + if not conn: + conn = M4Connection(hass, host, port) + hass.data[DOMAIN][(host, port)] = conn + conn.start() + + entities = [ + M4Cover( + conn, + host, + port, + cfg[CONF_NAME], + cfg[CONF_DEVICE], + cfg[CONF_CHANNEL], + ) + for cfg in covers_conf + ] + async_add_entities(entities, update_before_add=True) + + +class M4Cover(CoverEntity): + _attr_should_poll = False + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + def __init__( + self, + conn: M4Connection, + host: str, + port: int, + name: str, + device: int, + channel: int, + ): + self._conn = conn + self._host = host + self._port = port + self._attr_name = name + self._device = device + self._channel = channel + self._attr_unique_id = f"{host}-{port}-{device}-{channel}-cover" + + self._position: Optional[int] = None + + self._conn.register_shade_listener( + self._device, self._channel, self._handle_update + ) + + last_level = self._conn.get_last_shade_level(self._device, self._channel) + if last_level is not None: + self._handle_update(last_level) + + def _handle_update(self, level: int): + self._position = level + self.schedule_update_ha_state() + + @property + def current_cover_position(self) -> Optional[int]: + return self._position + + @property + def is_closed(self) -> Optional[bool]: + if self._position is None: + return None + return self._position == 0 + + async def async_open_cover(self, **kwargs): + self._conn.send_shade(self._device, self._channel, "UP") + + async def async_close_cover(self, **kwargs): + self._conn.send_shade(self._device, self._channel, "DOWN") + + async def async_stop_cover(self, **kwargs): + self._conn.send_shade(self._device, self._channel, "STOP") + + async def async_set_cover_position(self, **kwargs): + if "position" in kwargs: + pos = kwargs["position"] + self._conn.send_shade(self._device, self._channel, f"GOTO {pos}") diff --git a/custom_components/dinplug/hub.py b/custom_components/dinplug/hub.py new file mode 100644 index 0000000..4e2980a --- /dev/null +++ b/custom_components/dinplug/hub.py @@ -0,0 +1,242 @@ +import asyncio +import logging +from typing import Callable, Dict, Tuple, List, Optional, Any + +_LOGGER = logging.getLogger(__name__) + +KEEPALIVE_INTERVAL = 10 # seconds +RECONNECT_DELAY = 5 # seconds + +# Listener types +ListenLight = Callable[[int], None] +ListenShade = Callable[[int], None] +ListenButton = Callable[[str], None] +ListenHvac = Callable[[Dict[str, Any]], None] + + +class M4Connection: + """Single TCP/Telnet connection to the M4/DINPLUG controller.""" + + def __init__(self, hass, host: str, port: int): + self._hass = hass + self._host = host + self._port = port + self._writer: Optional[asyncio.StreamWriter] = None + self._reader: Optional[asyncio.StreamReader] = None + self._task: Optional[asyncio.Task] = None + self._connected = False + + # Listeners + self._light_listeners: Dict[Tuple[int, int], List[ListenLight]] = {} + self._shade_listeners: Dict[Tuple[int, int], List[ListenShade]] = {} + self._button_listeners: Dict[Tuple[int, int], List[ListenButton]] = {} + self._hvac_listeners: Dict[int, List[ListenHvac]] = {} + + # Caches for last known states + self._last_light_levels: Dict[Tuple[int, int], int] = {} + self._last_shade_levels: Dict[Tuple[int, int], int] = {} + self._last_button_states: Dict[Tuple[int, int], str] = {} + self._last_hvac_states: Dict[int, Dict[str, Any]] = {} + + def start(self): + """Start background connection loop.""" + if self._task is None: + self._task = self._hass.loop.create_task(self._run_loop()) + + async def _run_loop(self): + """Connect, read lines, reconnect if needed.""" + while True: + try: + _LOGGER.info("Connecting to M4 DINPLUG at %s:%s", self._host, self._port) + self._reader, self._writer = await asyncio.open_connection( + self._host, self._port + ) + self._connected = True + _LOGGER.info("M4 DINPLUG connected") + + try: + self.send_raw("REFRESH") + except Exception as e: + _LOGGER.debug("Failed to send REFRESH: %s", e) + + self._hass.loop.create_task(self._keepalive_loop()) + + while True: + line = await self._reader.readline() + if not line: + raise ConnectionError("EOF from controller") + text = line.decode(errors="ignore").strip() + if not text: + continue + self._handle_line(text) + + except Exception as e: + _LOGGER.warning("M4 DINPLUG connection error: %s", e) + finally: + self._connected = False + if self._writer: + try: + self._writer.close() + await self._writer.wait_closed() + except Exception: + pass + self._writer = None + self._reader = None + + _LOGGER.info("Reconnecting to M4 DINPLUG in %s seconds", RECONNECT_DELAY) + await asyncio.sleep(RECONNECT_DELAY) + + async def _keepalive_loop(self): + """Send STA keepalive while connected.""" + while self._connected and self._writer is not None: + try: + self.send_raw("STA") + except Exception as e: + _LOGGER.debug("Failed to send STA: %s", e) + await asyncio.sleep(KEEPALIVE_INTERVAL) + + def send_raw(self, cmd: str): + """Send a raw command with CRLF.""" + if not self._writer: + raise ConnectionError("Not connected to controller") + msg = (cmd + "\r\n").encode() + _LOGGER.debug("TX: %s", cmd) + self._writer.write(msg) + + # ---- Light methods ---- + + def send_load(self, device: int, channel: int, level: int, fade: Optional[int] = None): + level = max(0, min(100, int(level))) + cmd = f"LOAD {device} {channel} {level}" + (f" {fade:04d}" if fade else "") + self.send_raw(cmd) + + def register_light_listener(self, device: int, channel: int, callback: ListenLight): + self._light_listeners.setdefault((device, channel), []).append(callback) + + def get_last_light_level(self, device: int, channel: int) -> Optional[int]: + return self._last_light_levels.get((device, channel)) + + # ---- Shade methods ---- + + def send_shade(self, device: int, channel: int, command: str): + self.send_raw(f"SHADE {device} {channel} {command}") + + def register_shade_listener(self, device: int, channel: int, callback: ListenShade): + self._shade_listeners.setdefault((device, channel), []).append(callback) + + def get_last_shade_level(self, device: int, channel: int) -> Optional[int]: + return self._last_shade_levels.get((device, channel)) + + # ---- Button sensor methods ---- + + def register_button_listener(self, device: int, channel: int, callback: ListenButton): + self._button_listeners.setdefault((device, channel), []).append(callback) + + def get_last_button_state(self, device: int, channel: int) -> Optional[str]: + return self._last_button_states.get((device, channel)) + + # ---- HVAC methods ---- + + def send_hvac(self, device: int, command: str, value: Optional[Any] = None): + cmd = f"HVAC {device} {command}" + (f" {value}" if value is not None else "") + self.send_raw(cmd) + + def register_hvac_listener(self, device: int, callback: ListenHvac): + self._hvac_listeners.setdefault(device, []).append(callback) + + def get_last_hvac_state(self, device: int) -> Optional[Dict[str, Any]]: + return self._last_hvac_states.get(device) + + # ---- Central line handler ---- + + def _handle_line(self, text: str): + """Parse incoming lines and dispatch to correct handlers.""" + _LOGGER.debug("RX: %s", text) + parts = text.split() + if not parts: + return + + # R:LOAD + if parts[0] == "R:LOAD" and len(parts) >= 4: + self._handle_load(parts) + # R:SHADE + elif parts[0] == "R:SHADE" and len(parts) >= 4: + self._handle_shade(parts) + # R:BTN + elif parts[0] == "R:BTN" and len(parts) >= 4: + self._handle_btn(parts) + # R:HVAC ... + elif parts[0] == "R:HVAC" and len(parts) >= 3: + self._handle_hvac(parts) + + def _handle_load(self, parts: List[str]): + try: + dev, ch, level = map(int, parts[1:4]) + except (ValueError, IndexError): + return + + key = (dev, ch) + self._last_light_levels[key] = level + if key in self._light_listeners: + for cb in self._light_listeners[key]: + self._hass.add_job(cb, level) + + def _handle_shade(self, parts: List[str]): + try: + dev, ch, level = map(int, parts[1:4]) + except (ValueError, IndexError): + return + + key = (dev, ch) + self._last_shade_levels[key] = level + if key in self._shade_listeners: + for cb in self._shade_listeners[key]: + self._hass.add_job(cb, level) + + def _handle_btn(self, parts: List[str]): + try: + state = parts[1] + dev, ch = map(int, parts[2:4]) + except (ValueError, IndexError): + return + + key = (dev, ch) + self._last_button_states[key] = state + if key in self._button_listeners: + for cb in self._button_listeners[key]: + self._hass.add_job(cb, state) + + def _handle_hvac(self, parts: List[str]): + try: + # R:HVAC + if len(parts) == 3: + prop, dev_str = parts[1], parts[2] + dev = int(dev_str) + state = self._last_hvac_states.setdefault(dev, {}) + + if prop in {"COOL", "HEAT", "FAN", "OFF"}: + state["mode"] = prop.lower() + elif prop.startswith("FAN"): + state["fan_mode"] = prop[3:].lower() + + # R:HVAC + elif len(parts) == 4: + prop, dev_str, val_str = parts[1], parts[2], parts[3] + dev = int(dev_str) + state = self._last_hvac_states.setdefault(dev, {}) + + if prop == "CURRENTTEMP": + state["current_temperature"] = float(val_str) + elif prop == "SETPOINT": + state["temperature"] = float(val_str) + + else: + return # Unrecognized HVAC format + + # Dispatch update + if dev in self._hvac_listeners: + for cb in self._hvac_listeners[dev]: + self._hass.add_job(cb, state.copy()) + + except (ValueError, IndexError): + pass diff --git a/custom_components/dinplug/light.py b/custom_components/dinplug/light.py index 8331afe..1bc9363 100644 --- a/custom_components/dinplug/light.py +++ b/custom_components/dinplug/light.py @@ -19,12 +19,11 @@ CONF_CHANNEL, CONF_DIMMER, ) +from .hub import M4Connection _LOGGER = logging.getLogger(__name__) DEFAULT_PORT = 23 -KEEPALIVE_INTERVAL = 10 # seconds -RECONNECT_DELAY = 5 # seconds # ---------- YAML schema ---------- @@ -45,154 +44,6 @@ } ) -# ---------- Connection manager ---------- - - -class M4Connection: - """Single TCP/Telnet connection to the M4/DINPLUG controller.""" - - def __init__(self, hass, host: str, port: int): - self._hass = hass - self._host = host - self._port = port - self._writer: Optional[asyncio.StreamWriter] = None - self._reader: Optional[asyncio.StreamReader] = None - self._task: Optional[asyncio.Task] = None - self._listeners: Dict[Tuple[int, int], List[Callable[[int], None]]] = {} - self._connected = False - - # Cache de último nível conhecido por (device, channel) - self._last_levels: Dict[Tuple[int, int], int] = {} - - def start(self): - """Start background connection loop.""" - if self._task is None: - self._task = self._hass.loop.create_task(self._run_loop()) - - async def _run_loop(self): - """Connect, read lines, reconnect if needed.""" - while True: - try: - _LOGGER.info("Connecting to M4 DINPLUG at %s:%s", self._host, self._port) - self._reader, self._writer = await asyncio.open_connection( - self._host, self._port - ) - self._connected = True - _LOGGER.info("M4 DINPLUG connected") - - # Ao conectar, pede um REFRESH para o controlador mandar o estado de tudo - try: - self.send_raw("REFRESH") - except Exception as e: - _LOGGER.debug("Failed to send REFRESH: %s", e) - - # Kick off keepalive - self._hass.loop.create_task(self._keepalive_loop()) - - # Read loop - while True: - line = await self._reader.readline() - if not line: - raise ConnectionError("EOF from controller") - text = line.decode(errors="ignore").strip() - if not text: - continue - self._handle_line(text) - - except Exception as e: - _LOGGER.warning("M4 DINPLUG connection error: %s", e) - finally: - self._connected = False - if self._writer: - try: - self._writer.close() - await self._writer.wait_closed() - except Exception: - pass - self._writer = None - self._reader = None - - _LOGGER.info("Reconnecting to M4 DINPLUG in %s seconds", RECONNECT_DELAY) - await asyncio.sleep(RECONNECT_DELAY) - - async def _keepalive_loop(self): - """Send STA keepalive while connected.""" - while self._connected and self._writer is not None: - try: - self.send_raw("STA") - except Exception as e: - _LOGGER.debug("Failed to send STA: %s", e) - await asyncio.sleep(KEEPALIVE_INTERVAL) - - def send_raw(self, cmd: str): - """Send a raw command with CRLF.""" - if not self._writer: - raise ConnectionError("Not connected to controller") - msg = (cmd + "\r\n").encode() - _LOGGER.debug("TX: %s", cmd) - self._writer.write(msg) - - # ---- Protocol-specific helpers ---- - - def send_load(self, device: int, channel: int, level: int, fade: Optional[int] = None): - """Send LOAD command. - - If fade is None -> LOAD dev ch level - Else -> LOAD dev ch level fade - """ - level = max(0, min(100, int(level))) - if fade is None: - cmd = f"LOAD {device} {channel} {level}" - else: - cmd = f"LOAD {device} {channel} {level:03d} {fade:04d}" - _LOGGER.debug("SEND: %s", cmd) - self.send_raw(cmd) - - def send_switch(self, device: int, channel: int, on: bool): - """Switch-style LOAD.""" - level = 100 if on else 0 - cmd = f"LOAD {device} {channel} {level}" - _LOGGER.debug("SEND: %s", cmd) - self.send_raw(cmd) - - def register_load_listener( - self, device: int, channel: int, callback: Callable[[int], None] - ): - key = (device, channel) - self._listeners.setdefault(key, []).append(callback) - - def get_last_level(self, device: int, channel: int) -> Optional[int]: - """Return last known level for a given device/channel (from R:LOAD).""" - return self._last_levels.get((device, channel)) - - def _handle_line(self, text: str): - """Parse incoming lines and dispatch R:LOAD.""" - _LOGGER.debug("RX: %s", text) - - # Example: R:LOAD 104 3 50 - if text.startswith("R:LOAD "): - parts = text.split() - if len(parts) >= 4: - try: - dev = int(parts[1]) - ch = int(parts[2]) - level = int(parts[3]) - except ValueError: - return - - key = (dev, ch) - - # Guarda último nível conhecido - self._last_levels[key] = level - - if key in self._listeners: - for cb in self._listeners[key]: - # Run callbacks in HA loop safely - self._hass.add_job(cb, level) - - # Aqui no futuro dá pra extender: R:SCN, R:HVAC, MODULE STATUS etc. - - # ---------- Platform setup ---------- async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -259,14 +110,14 @@ def __init__( self._attr_unique_id = f"{self._host}-{self._port}-{self._device}-{self._channel}" # Register for R:LOAD updates for this device/channel - self._conn.register_load_listener( + self._conn.register_light_listener( self._device, self._channel, self._handle_level_update, ) # Se já tivemos um R:LOAD antes da entidade subir (por REFRESH), usa esse estado - last = self._conn.get_last_level(self._device, self._channel) + last = self._conn.get_last_light_level(self._device, self._channel) if last is not None: self._handle_level_update(last) diff --git a/custom_components/dinplug/manifest.json b/custom_components/dinplug/manifest.json index e0c71d5..27487ef 100644 --- a/custom_components/dinplug/manifest.json +++ b/custom_components/dinplug/manifest.json @@ -1,11 +1,12 @@ { "domain": "dinplug", "name": "DINPLUG Lighting", - "version": "0.1.0", + "version": "0.2.0", "documentation": "https://example.com/dinplug", "requirements": [], "codeowners": ["@devprbtt"], "integration_type": "hub", "iot_class": "local_push", - "loggers": ["custom_components.dinplug"] + "loggers": ["custom_components.dinplug"], + "platforms": ["light", "climate", "cover", "sensor"] } diff --git a/custom_components/dinplug/sensor.py b/custom_components/dinplug/sensor.py new file mode 100644 index 0000000..470ec58 --- /dev/null +++ b/custom_components/dinplug/sensor.py @@ -0,0 +1,95 @@ +import logging +from typing import Optional + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_NAME +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, CONF_DEVICE, CONF_CHANNEL +from .hub import M4Connection + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PORT = 23 + +CONF_SENSORS = "buttons" + +BUTTON_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_DEVICE): vol.Coerce(int), + vol.Required(CONF_CHANNEL): vol.Coerce(int), + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_SENSORS): vol.All(cv.ensure_list, [BUTTON_SCHEMA]), + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up dinplug button sensors from YAML.""" + host = config[CONF_HOST] + port = config[CONF_PORT] + sensors_conf = config[CONF_SENSORS] + + conn = hass.data[DOMAIN].get((host, port)) + if not conn: + conn = M4Connection(hass, host, port) + hass.data[DOMAIN][(host, port)] = conn + conn.start() + + entities = [ + M4ButtonSensor( + conn, + host, + port, + cfg[CONF_NAME], + cfg[CONF_DEVICE], + cfg[CONF_CHANNEL], + ) + for cfg in sensors_conf + ] + async_add_entities(entities, update_before_add=True) + + +class M4ButtonSensor(SensorEntity): + _attr_should_poll = False + _attr_icon = "mdi:radiobox-marked" + + def __init__( + self, + conn: M4Connection, + host: str, + port: int, + name: str, + device: int, + channel: int, + ): + self._conn = conn + self._host = host + self._port = port + self._attr_name = name + self._device = device + self._channel = channel + self._attr_unique_id = f"{host}-{port}-{device}-{channel}-button" + + self._attr_native_value = None + + self._conn.register_button_listener( + self._device, self._channel, self._handle_update + ) + + last_state = self._conn.get_last_button_state(self._device, self._channel) + if last_state: + self._handle_update(last_state) + + def _handle_update(self, state: str): + self._attr_native_value = state.lower() + self.schedule_update_ha_state()