├── hacs.json ├── custom_components └── marshydro │ ├── const.py │ ├── manifest.json │ ├── strings.json │ ├── __init__.py │ ├── config_flow.py │ ├── fan.py │ ├── switch.py │ ├── light.py │ ├── api.py │ └── sensor.py ├── .github └── workflows │ ├── main.yml │ └── Action.yaml ├── translations └── en.json ├── LICENSE ├── changelog.md ├── CONTRIBUTING.md └── README.md /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "marshydro", 3 | "render_readme": true 4 | } -------------------------------------------------------------------------------- /custom_components/marshydro/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "marshydro" 2 | CONF_USERNAME = "username" 3 | CONF_PASSWORD = "password" -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /.github/workflows/Action.yaml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | hacs: 11 | name: HACS Action 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS Action 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /custom_components/marshydro/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "marshydro", 3 | "name": "Mars Hydro Cloud Integration", 4 | "codeowners": ["@suppqt"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/suppqt/hass_mars_hydro", 8 | "iot_class": "local_push", 9 | "issue_tracker": "https://github.com/suppqt/hass_mars_hydro/issues", 10 | "requirements": ["requests>=2.26.0"], 11 | "version": "1.0.4" 12 | } 13 | -------------------------------------------------------------------------------- /translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured" 5 | }, 6 | "error": { 7 | "cannot_connect": "Failed to connect", 8 | "invalid_auth": "Invalid authentication", 9 | "unknown": "Unexpected error" 10 | }, 11 | "step": { 12 | "user": { 13 | "data": { 14 | "host": "Host", 15 | "password": "Password", 16 | "username": "Username" 17 | } 18 | } 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /custom_components/marshydro/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "data": { 6 | "host": "[%key:common::config_flow::data::host%]", 7 | "username": "[%key:common::config_flow::data::username%]", 8 | "password": "[%key:common::config_flow::data::password%]" 9 | } 10 | } 11 | }, 12 | "error": { 13 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 14 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", 15 | "unknown": "[%key:common::config_flow::error::unknown%]" 16 | }, 17 | "abort": { 18 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 suppqt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Version 1.0.3 4 | 5 | - fixxed missing toggle_switch function 6 | 7 | ## Version 1.0.2 8 | 9 | ### 🚀 New Features 10 | 11 | - **Fan Entity** 12 | - Added a fan entity with speed control via a slider (25%-100%). 13 | - Initial slider value uses `deviceLightRate` from `get_fandata`. 14 | - Included `async_turn_on`, `async_turn_off`, and detailed logging for improved control. 15 | 16 | - **Fan Sensors** 17 | - Introduced new sensors to monitor: 18 | - **Temperature** (°F and °C). 19 | - **Humidity**. 20 | - **Fan speed**. 21 | - Handles invalid or missing data gracefully with enhanced logging. 22 | 23 | ### 🔧 API Updates 24 | 25 | - Added a new `set_fanspeed` method, based on `set_brightness`, to control fan speed. 26 | - Enhanced logging for all API calls, including detailed request and response data. 27 | 28 | ### 🖼️ Device Registry 29 | 30 | - Integrated device images into Home Assistant using the `deviceImage` URL from `get_lightdata` and `get_fandata`. 31 | 32 | ### 🐛 Bug Fixes 33 | 34 | - Fixed fan and light device ID mix-up issues. 35 | - Ensured fan speed values are clamped to the valid range of 25%-100%. 36 | 37 | ### 📈 General Improvements 38 | 39 | - Enhanced logging for debugging and monitoring. 40 | - Improved dynamic handling of device names and IDs. 41 | - Added robust error handling for a more seamless integration. 42 | 43 | --- 44 | 45 | This release introduces fan support, expands sensor functionality, and significantly improves the integration's stability and usability. 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Contributing to this project should be as easy and transparent as possible, whether it's: 4 | 5 | - Reporting a bug 6 | - Discussing the current state of the code 7 | - Submitting a fix 8 | - Proposing new features 9 | 10 | ## Github is used for everything 11 | 12 | Github is used to host code, to track issues and feature requests, as well as accept pull requests. 13 | 14 | Pull requests are the best way to propose changes to the codebase. 15 | 16 | 1. Fork the repo and create your branch from `main`. 17 | 2. If you've changed something, update the documentation. 18 | 3. Make sure your code lints (using `scripts/lint`). 19 | 4. Test you contribution. 20 | 5. Issue that pull request! 21 | 22 | ## Any contributions you make will be under the MIT Software License 23 | 24 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 25 | 26 | ## Report bugs using Github's [issues](../../issues) 27 | 28 | GitHub issues are used to track public bugs. 29 | Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! 30 | 31 | ## Write bug reports with detail, background, and sample code 32 | 33 | **Great Bug Reports** tend to have: 34 | 35 | - A quick summary and/or background 36 | - Steps to reproduce 37 | - Be specific! 38 | - Give sample code if you can. 39 | - What you expected would happen 40 | - What actually happens 41 | - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) 42 | 43 | People *love* thorough bug reports. I'm not even kidding. 44 | 45 | ## Use a Consistent Coding Style 46 | 47 | Use [black](https://github.com/ambv/black) to make sure the code follows the style. 48 | 49 | ## Test your code modification 50 | 51 | This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint). 52 | 53 | ## License 54 | 55 | By contributing, you agree that your contributions will be licensed under its MIT License. 56 | -------------------------------------------------------------------------------- /custom_components/marshydro/__init__.py: -------------------------------------------------------------------------------- 1 | from homeassistant.core import HomeAssistant 2 | from homeassistant.config_entries import ConfigEntry 3 | from homeassistant.helpers.typing import ConfigType 4 | from homeassistant.helpers import device_registry as dr 5 | from .const import DOMAIN 6 | import logging 7 | from .api import MarsHydroAPI 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | PLATFORMS = ["sensor", "light", "switch", "fan"] # Sensor hinzugefügt 12 | 13 | 14 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 15 | """Setup für die Mars Hydro-Integration.""" 16 | hass.data.setdefault(DOMAIN, {}) 17 | return True 18 | 19 | 20 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 21 | """Set up Mars Hydro integration from a config entry.""" 22 | email = entry.data["email"] 23 | password = entry.data["password"] 24 | 25 | api = MarsHydroAPI(email, password) 26 | await api.login() 27 | 28 | hass.data.setdefault(DOMAIN, {}) 29 | hass.data[DOMAIN][entry.entry_id] = {"api": api} 30 | 31 | # Gerät registrieren 32 | device_registry = dr.async_get(hass) 33 | 34 | # Light-Gerät registrieren 35 | light_data = await api.get_lightdata() 36 | if light_data: 37 | device_registry.async_get_or_create( 38 | config_entry_id=entry.entry_id, 39 | identifiers={(DOMAIN, light_data["id"])}, 40 | manufacturer="Mars Hydro", 41 | name=light_data["deviceName"], 42 | model="Mars Hydro Light", 43 | ) 44 | _LOGGER.info( 45 | f"Light Device {light_data['deviceName']} wurde erfolgreich registriert." 46 | ) 47 | else: 48 | _LOGGER.warning("Kein Light-Gerät gefunden, Registrierung übersprungen.") 49 | 50 | # Fan-Gerät registrieren 51 | fan_data = await api.get_fandata() 52 | if fan_data: 53 | device_registry.async_get_or_create( 54 | config_entry_id=entry.entry_id, 55 | identifiers={(DOMAIN, fan_data["id"])}, 56 | manufacturer="Mars Hydro", 57 | name=fan_data["deviceName"], 58 | model="Mars Hydro Fan", 59 | ) 60 | _LOGGER.info( 61 | f"Fan Device {fan_data['deviceName']} wurde erfolgreich registriert." 62 | ) 63 | else: 64 | _LOGGER.warning("Kein Fan-Gerät gefunden, Registrierung übersprungen.") 65 | 66 | # Plattformen laden 67 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 68 | 69 | return True 70 | 71 | 72 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 73 | """Entferne eine Konfigurationsinstanz.""" 74 | _LOGGER.debug("Mars Hydro async_unload_entry wird aufgerufen") 75 | 76 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 77 | 78 | if unload_ok: 79 | hass.data[DOMAIN].pop(entry.entry_id) 80 | 81 | return unload_ok 82 | 83 | 84 | async def create_api_instance(hass: HomeAssistant, email: str, password: str): 85 | """Erstelle eine API-Instanz und führe den Login durch.""" 86 | try: 87 | api_instance = MarsHydroAPI(email, password) 88 | await api_instance.login() 89 | return api_instance 90 | except Exception as e: 91 | _LOGGER.error(f"Fehler beim Erstellen der API-Instanz: {e}") 92 | return None 93 | -------------------------------------------------------------------------------- /custom_components/marshydro/config_flow.py: -------------------------------------------------------------------------------- 1 | from homeassistant import config_entries 2 | from homeassistant.core import callback 3 | from homeassistant.data_entry_flow import FlowResult 4 | import voluptuous as vol 5 | from .const import DOMAIN 6 | import logging 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | class MarsHydroConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 12 | """Handle a config flow for Mars Hydro.""" 13 | 14 | VERSION = 1 15 | 16 | def __init__(self): 17 | self._email = None 18 | self._password = None 19 | 20 | async def async_step_user(self, user_input=None) -> FlowResult: 21 | """Handle the initial step.""" 22 | errors = {} 23 | 24 | if user_input is not None: 25 | self._email = user_input["email"] 26 | self._password = user_input["password"] 27 | 28 | if not self._validate_email(self._email): 29 | errors["email"] = "invalid_email" 30 | else: 31 | login_success = await self._test_login(self._email, self._password) 32 | if login_success: 33 | return self.async_create_entry( 34 | title="Mars Hydro", 35 | data={"email": self._email, "password": self._password}, 36 | ) 37 | else: 38 | errors["base"] = "cannot_connect" 39 | 40 | # Schema für das Eingabeformular 41 | data_schema = vol.Schema( 42 | { 43 | vol.Required("email"): str, 44 | vol.Required("password"): str, 45 | } 46 | ) 47 | 48 | return self.async_show_form( 49 | step_id="user", 50 | data_schema=data_schema, 51 | errors=errors, 52 | ) 53 | 54 | async def _test_login(self, email: str, password: str) -> bool: 55 | """Test the API login.""" 56 | from .api import MarsHydroAPI 57 | 58 | api = MarsHydroAPI(email, password) 59 | try: 60 | await api.login() 61 | return True 62 | except Exception as e: 63 | _LOGGER.error("Error testing login credentials: %s", e) 64 | return False 65 | 66 | @staticmethod 67 | def _validate_email(email: str) -> bool: 68 | """Validate an email address.""" 69 | import re 70 | 71 | email_regex = r"^[\w\.-]+@[\w\.-]+\.\w+$" 72 | return re.match(email_regex, email) is not None 73 | 74 | @staticmethod 75 | @callback 76 | def async_get_options_flow(config_entry: config_entries.ConfigEntry): 77 | """Get the options flow.""" 78 | return MarsHydroOptionsFlow(config_entry) 79 | 80 | 81 | class MarsHydroOptionsFlow(config_entries.OptionsFlow): 82 | """Handle an options flow for Mars Hydro.""" 83 | 84 | def __init__(self, config_entry: config_entries.ConfigEntry): 85 | self.config_entry = config_entry 86 | 87 | async def async_step_init(self, user_input=None) -> FlowResult: 88 | """Handle the options flow.""" 89 | errors = {} 90 | 91 | if user_input is not None: 92 | return self.async_create_entry(title="", data=user_input) 93 | 94 | options_schema = vol.Schema( 95 | { 96 | vol.Required("update_interval", default=30): int, 97 | } 98 | ) 99 | 100 | return self.async_show_form( 101 | step_id="init", 102 | data_schema=options_schema, 103 | errors=errors, 104 | ) 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HA Mars Hydro 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![GitHub Activity][commits-shield]][commits] 5 | [![License][license-shield]](LICENSE) 6 | 7 | [![hacs][hacsbadge]][hacs] 8 | ![Project Maintenance][maintenance-shield] 9 | 10 | [![Community Forum][forum-shield]][forum] 11 | 12 | ## Mars Hydro Cloud Integration 13 | This integration communicates with the Mars Hydro Cloud and controls and monitors your Mars Hydro devices (lights and fans) through Home Assistant. 14 | 15 | ⚠️ Warning: API only supports one device to be logged in, so you will get kicked out of the app as soon as you login. Also make sure, you use the MarsHydro App, not MarsPro. 16 | 17 | ## Additional Note 18 | Since I only own one device (an FC3000 Light), I initially focused on supporting that device. However, support for **fans** and their controls has now been added. If you have the Bluetooth Stick, this integration should work with your devices. 19 | 20 | ## Features Added: 21 | - **Fan Entity**: 22 | - Control fan speed via a slider (25%-100%). 23 | - Monitor fan speed as a percentage. 24 | - **Fan Sensors**: 25 | - **Temperature (°F and °C)**. 26 | - **Humidity**. 27 | - **Fan speed**. 28 | - **Device Images**: (work in progress) 29 | - Device images are getting displayed in Home Assistant soon 30 | 31 | ## Background 32 | - This integration is designed for **Mars Hydro FC...** lights and compatible fans running with the Bluetooth USB Stick. 33 | - It allows you to: 34 | - Control light brightness and fan speed. 35 | - Control device power via a switch. 36 | - Monitor brightness, temperature, humidity, and fan speed. 37 | - This integration is built for the Home Assistant platform to manage your Mars Hydro devices through the cloud API. 38 | 39 | ## Setup 40 | 41 | ### Installation: 42 | * Go to HACS -> Integrations 43 | * Click the three dots on the top right and select `Custom Repositories` 44 | * Enter `https://github.com/suppqt/ha_mars_hydro` as the repository, select the category `Integration` and click Add. 45 | * A new custom integration called **Mars Hydro** should now show up in your HACS. Install it. 46 | * Restart Home Assistant. 47 | 48 | ### Configuration: 49 | 1. **Login and Connect Devices in the Mars Hydro App**: 50 | - Before using this integration, ensure you have logged into the **Mars Hydro app** and connected your devices. 51 | 52 | 2. **Login**: 53 | - The integration will require your **email** and **password** from the Mars Hydro app. 54 | 55 | 3. **Automatic Device Discovery**: 56 | - The integration will fetch device data and create entities for: 57 | - **Light brightness control**. 58 | - **Fan speed control**. 59 | - **Temperature (°F/°C)**. 60 | - **Humidity**. 61 | - **Fan speed sensor**. 62 | - **Switch control for lights and fans**. 63 | 64 | ### Entities Created: 65 | - **Light Brightness Control**: Adjust brightness of your Mars Hydro light. 66 | - **Fan Speed Control**: Adjust fan speed (slider, 25%-100%). 67 | - **Temperature Sensors**: Displays fan temperature in °F and °C. 68 | - **Humidity Sensor**: Displays fan humidity. 69 | - **Fan Speed Sensor**: Displays fan speed percentage. 70 | - **Switch Control**: Power on/off for lights and fans. 71 | 72 | #### Notes: 73 | - This integration uses the **Mars Hydro Cloud API**. Ensure your devices are connected to the cloud and reachable. 74 | - You may need to create an account in the Mars Hydro app and provide your credentials to authenticate and link your device. 75 | 76 | #### Disclaimer: 77 | - This is my first custom component, and while I strive for quality, there may still be issues. Feedback and contributions are always appreciated! 78 | 79 | ## Contributions are welcome! 80 | 81 | If you want to contribute to this integration, please read the [Contribution guidelines](CONTRIBUTING.md). 82 | 83 | *** 84 | 85 | [hacs]: https://github.com/hacs/integration 86 | [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge 87 | [commits-shield]: https://img.shields.io/github/commit-activity/y/suppqt/ha_mars_hydro.svg?style=for-the-badge 88 | [commits]: https://github.com/suppqt/ha_mars_hydro/commits/main 89 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge 90 | [forum]: https://community.home-assistant.io/ 91 | [license-shield]: https://img.shields.io/github/license/suppqt/ha_mars_hydro.svg?style=for-the-badge 92 | [maintenance-shield]: https://img.shields.io/badge/maintainer-%20%40suppqt-blue.svg?style=for-the-badge 93 | [releases-shield]: https://img.shields.io/github/release/suppqt/ha_mars_hydro.svg?style=for-the-badge 94 | [releases]: https://github.com/suppqt/ha_mars_hydro/releases 95 | -------------------------------------------------------------------------------- /custom_components/marshydro/fan.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.fan import FanEntity, FanEntityFeature 2 | from . import _LOGGER, DOMAIN 3 | 4 | 5 | async def async_setup_entry(hass, entry, async_add_entities): 6 | """Set up the Mars Hydro fan entity.""" 7 | api = hass.data[DOMAIN][entry.entry_id].get("api") 8 | 9 | if api: 10 | fan_entity = MarsHydroFanEntity(api, entry.entry_id) 11 | async_add_entities([fan_entity], update_before_add=True) 12 | _LOGGER.info("Mars Hydro fan entity added successfully.") 13 | else: 14 | _LOGGER.error("API instance not found. Cannot set up fan entity.") 15 | 16 | 17 | class MarsHydroFanEntity(FanEntity): 18 | """Representation of a Mars Hydro fan.""" 19 | 20 | def __init__(self, api, entry_id): 21 | self._api = api 22 | self._device_id = None 23 | self._device_name = None 24 | self._speed_percentage = None 25 | self._available = True 26 | self._entry_id = entry_id 27 | 28 | @property 29 | def name(self): 30 | """Return the name of the fan.""" 31 | if self._device_name and self._device_id: 32 | return f"{self._device_name} Fan ({self._device_id})" 33 | elif self._device_name: 34 | return f"{self._device_name} Fan" 35 | return "Mars Hydro Fan" 36 | 37 | @property 38 | def available(self): 39 | """Return True if the fan is available.""" 40 | return self._available 41 | 42 | @property 43 | def percentage(self): 44 | """Return the current speed percentage of the fan.""" 45 | return self._speed_percentage 46 | 47 | @property 48 | def unique_id(self): 49 | """Return a unique ID for the fan.""" 50 | return ( 51 | f"{self._entry_id}_fan_{self._device_id}" 52 | if self._device_id 53 | else f"{self._entry_id}_fan" 54 | ) 55 | 56 | @property 57 | def device_info(self): 58 | """Return device information for linking with the device registry.""" 59 | if not self._device_id or not self._device_name: 60 | _LOGGER.warning("Device info incomplete for fan entity.") 61 | return None 62 | 63 | return { 64 | "identifiers": {(DOMAIN, self._device_id)}, 65 | "name": self._device_name, 66 | "manufacturer": "Mars Hydro", 67 | "model": "Mars Hydro Fan", 68 | } 69 | 70 | @property 71 | def supported_features(self): 72 | """Return supported features of the fan.""" 73 | return FanEntityFeature.SET_SPEED # Support speed adjustment only 74 | 75 | async def async_set_percentage(self, percentage): 76 | """Set the fan speed percentage.""" 77 | if percentage < 25: 78 | _LOGGER.warning("Fan speed percentage below 25% is not allowed.") 79 | percentage = 25 80 | 81 | if percentage > 100: 82 | _LOGGER.warning("Fan speed percentage above 100% is not allowed.") 83 | percentage = 100 84 | 85 | try: 86 | response = await self._api.set_fanspeed(round(percentage), self._device_id) 87 | if response.get("code") == "000": 88 | self._speed_percentage = percentage 89 | _LOGGER.info(f"Fan speed set to {percentage}% successfully.") 90 | else: 91 | _LOGGER.error(f"Error setting fan speed: {response.get('msg')}") 92 | except Exception as e: 93 | _LOGGER.error(f"Error in async_set_percentage: {e}") 94 | self._available = False 95 | 96 | async def async_update(self): 97 | """Update the fan state.""" 98 | try: 99 | fan_data = await self._api.safe_api_call(self._api.get_fandata) 100 | if fan_data: 101 | self._device_id = fan_data["id"] 102 | self._device_name = fan_data["deviceName"] 103 | raw_speed = fan_data.get( 104 | "deviceLightRate", 25 105 | ) # Use deviceLightRate as the default slider value 106 | 107 | try: 108 | # Convert speed to integer and clamp it 109 | self._speed_percentage = min(max(int(raw_speed), 25), 100) 110 | self._available = True 111 | _LOGGER.info( 112 | f"Fan state updated: {self._speed_percentage}% for {self._device_name}" 113 | ) 114 | except ValueError: 115 | _LOGGER.warning( 116 | f"Invalid speed data for fan {self._device_name}: {raw_speed}" 117 | ) 118 | self._speed_percentage = None 119 | self._available = False 120 | else: 121 | self._available = False 122 | _LOGGER.warning("Could not update fan state.") 123 | except Exception as e: 124 | self._available = False 125 | _LOGGER.error(f"Error updating fan state: {e}") 126 | -------------------------------------------------------------------------------- /custom_components/marshydro/switch.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.switch import SwitchEntity 2 | from . import _LOGGER, DOMAIN 3 | 4 | 5 | async def async_setup_entry(hass, entry, async_add_entities): 6 | """Set up the switch platform.""" 7 | api = hass.data[DOMAIN][entry.entry_id].get("api") 8 | 9 | if api: 10 | light_switch = MarsHydroSwitch(api, entry.entry_id, device_type="LIGHT") 11 | fan_switch = MarsHydroSwitch(api, entry.entry_id, device_type="WIND") 12 | async_add_entities([light_switch, fan_switch], update_before_add=True) 13 | 14 | 15 | class MarsHydroSwitch(SwitchEntity): 16 | """Representation of a Mars Hydro switch.""" 17 | 18 | def __init__(self, api, entry_id, device_type): 19 | self._api = api 20 | self._device_id = None # To store the dynamic device_id 21 | self._device_name = None # To store the dynamic deviceName 22 | self._state = None 23 | self._available = True 24 | self._entry_id = entry_id 25 | self._device_type = device_type # LIGHT or WIND 26 | 27 | @property 28 | def name(self): 29 | """Return the name of the switch, dynamically including the device name and ID.""" 30 | if self._device_name and self._device_id: 31 | return f"{self._device_name} Switch ({self._device_id})" 32 | elif self._device_name: 33 | return f"{self._device_name} Switch" 34 | return f"Mars Hydro {self._device_type.capitalize()} Switch" 35 | 36 | @property 37 | def is_on(self): 38 | """Return True if the switch is on.""" 39 | return self._state 40 | 41 | @property 42 | def available(self): 43 | """Return True if the switch is available.""" 44 | return self._available 45 | 46 | @property 47 | def unique_id(self): 48 | """Return a unique ID for the switch.""" 49 | return ( 50 | f"{self._entry_id}_switch_{self._device_id}" 51 | if self._device_id 52 | else f"{self._entry_id}_switch_{self._device_type}" 53 | ) 54 | 55 | @property 56 | def device_info(self): 57 | """Return device information for linking with the device registry.""" 58 | if not self._device_id or not self._device_name: 59 | return None 60 | 61 | return { 62 | "identifiers": { 63 | (DOMAIN, self._device_id) 64 | }, # Match the registered device ID 65 | "name": self._device_name, # Use the dynamic deviceName 66 | "manufacturer": "Mars Hydro", 67 | "model": f"Mars Hydro {self._device_type.capitalize()}", 68 | } 69 | 70 | async def async_turn_on(self, **kwargs): 71 | """Turn the device on.""" 72 | try: 73 | if not self._device_id: 74 | _LOGGER.error("Device ID is not available; cannot turn on.") 75 | return 76 | 77 | response = await self._api.safe_api_call( 78 | self._api.toggle_switch, False, self._device_id 79 | ) 80 | if response.get("code") == "000": 81 | self._state = True 82 | _LOGGER.info(f"Switch '{self._device_name}' turned on successfully.") 83 | else: 84 | _LOGGER.error(f"Error turning on switch: {response.get('msg')}") 85 | except Exception as e: 86 | _LOGGER.error(f"Error in async_turn_on: {e}") 87 | self._available = False 88 | 89 | async def async_turn_off(self, **kwargs): 90 | """Turn the device off.""" 91 | try: 92 | if not self._device_id: 93 | _LOGGER.error("Device ID is not available; cannot turn off.") 94 | return 95 | 96 | response = await self._api.safe_api_call( 97 | self._api.toggle_switch, True, self._device_id 98 | ) 99 | if response.get("code") == "000": 100 | self._state = False 101 | _LOGGER.info(f"Switch '{self._device_name}' turned off successfully.") 102 | else: 103 | _LOGGER.error(f"Error turning off switch: {response.get('msg')}") 104 | except Exception as e: 105 | _LOGGER.error(f"Error in async_turn_off: {e}") 106 | self._available = False 107 | 108 | async def async_update(self): 109 | """Update the state of the switch.""" 110 | try: 111 | # Choose the correct API endpoint based on device type 112 | device_data = await self._api.safe_api_call( 113 | self._api.get_lightdata 114 | if self._device_type == "LIGHT" 115 | else self._api.get_fandata 116 | ) 117 | if device_data: 118 | self._device_id = device_data["id"] # Set device_id dynamically 119 | self._device_name = device_data[ 120 | "deviceName" 121 | ] # Set deviceName dynamically 122 | self._state = not device_data["isClose"] 123 | self._available = True 124 | _LOGGER.info( 125 | f"Switch state updated: {'ON' if self._state else 'OFF'} for {self._device_name}" 126 | ) 127 | else: 128 | _LOGGER.warning( 129 | f"Could not update switch state for {self._device_type}." 130 | ) 131 | self._available = False 132 | except Exception as e: 133 | _LOGGER.error(f"Error updating switch state for {self._device_type}: {e}") 134 | self._available = False 135 | -------------------------------------------------------------------------------- /custom_components/marshydro/light.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.light import LightEntity, ATTR_BRIGHTNESS 2 | from . import _LOGGER, DOMAIN 3 | 4 | 5 | async def async_setup_entry(hass, entry, async_add_entities): 6 | """Set up the Mars Hydro Light entity.""" 7 | _LOGGER.debug("Mars Hydro Light async_setup_entry called") 8 | 9 | api = hass.data[DOMAIN][entry.entry_id].get("api") 10 | 11 | if api: 12 | light = MarsHydroBrightnessLight(api, entry.entry_id) 13 | async_add_entities([light], update_before_add=True) 14 | 15 | 16 | class MarsHydroBrightnessLight(LightEntity): 17 | """Representation of the Mars Hydro Light with brightness control only.""" 18 | 19 | def __init__(self, api, entry_id): 20 | self._api = api 21 | self._device_id = None # To store the dynamic device_id 22 | self._device_name = None # To store the dynamic deviceName 23 | self._brightness = None 24 | self._available = False 25 | self._state = None 26 | self._entry_id = entry_id 27 | 28 | @property 29 | def name(self): 30 | """Return the name of the light, dynamically including the device name and ID.""" 31 | if self._device_name and self._device_id: 32 | return f"{self._device_name} ({self._device_id})" 33 | elif self._device_name: 34 | return self._device_name 35 | return "Mars Hydro Brightness Light" 36 | 37 | @property 38 | def brightness(self): 39 | """Return the brightness of the light (0-255).""" 40 | return self._brightness 41 | 42 | @property 43 | def available(self): 44 | """Return True if entity is available.""" 45 | return self._available 46 | 47 | @property 48 | def is_on(self): 49 | """Return True if the light is on.""" 50 | return self._state 51 | 52 | @property 53 | def unique_id(self): 54 | """Return a unique ID for the light.""" 55 | return ( 56 | f"{self._entry_id}_light_{self._device_id}" 57 | if self._device_id 58 | else f"{self._entry_id}_light" 59 | ) 60 | 61 | @property 62 | def device_info(self): 63 | """Return device information for linking with the device registry.""" 64 | if not self._device_id or not self._device_name: 65 | return None 66 | 67 | return { 68 | "identifiers": { 69 | (DOMAIN, self._device_id) 70 | }, # Match the registered device ID 71 | "name": self._device_name, # Use the dynamic deviceName 72 | "manufacturer": "Mars Hydro", 73 | "model": "Mars Hydro Light", 74 | } 75 | 76 | @property 77 | def supported_color_modes(self): 78 | """Return the list of supported color modes.""" 79 | return {"brightness"} 80 | 81 | @property 82 | def color_mode(self): 83 | """Return the current color mode.""" 84 | return "brightness" 85 | 86 | async def async_turn_on(self, **kwargs): 87 | """Turn on the light by setting the brightness.""" 88 | brightness = kwargs.get(ATTR_BRIGHTNESS, 255) # Default to max brightness 89 | await self.async_set_brightness(brightness) 90 | self._state = True 91 | 92 | async def async_turn_off(self, **kwargs): 93 | """Turn off the light by setting brightness to 0.""" 94 | await self.async_set_brightness(0) 95 | self._state = False 96 | 97 | async def async_set_brightness(self, brightness: int): 98 | """Set the brightness of the light.""" 99 | try: 100 | brightness_percentage = round((brightness / 255) * 100) 101 | response = await self._api.safe_api_call( 102 | self._api.set_brightness, brightness_percentage 103 | ) 104 | if response.get("code") == "102": 105 | _LOGGER.warning("Token expired, re-authenticating...") 106 | await self._api.login() 107 | response = await self._api.safe_api_call( 108 | self._api.set_brightness, brightness_percentage 109 | ) 110 | 111 | if response.get("code") != "000": 112 | raise Exception(f"API Error: {response.get('msg')}") 113 | 114 | self._brightness = brightness 115 | self._state = brightness > 0 116 | self._available = True 117 | _LOGGER.info(f"Brightness set to {brightness_percentage}%") 118 | except Exception as e: 119 | self._available = False 120 | _LOGGER.error(f"Error setting brightness: {e}") 121 | 122 | async def async_update(self): 123 | """Update the light's state.""" 124 | try: 125 | light_data = await self._api.safe_api_call(self._api.get_lightdata) 126 | if light_data: 127 | self._device_id = light_data[ 128 | "id" 129 | ] # Set device_id dynamically from the API response 130 | self._device_name = light_data[ 131 | "deviceName" 132 | ] # Set deviceName dynamically 133 | self._brightness = int((light_data["deviceLightRate"] / 100) * 255) 134 | self._state = not light_data["isClose"] 135 | self._available = True 136 | _LOGGER.info( 137 | f"Updated light: {self._device_name}, brightness: {self._brightness}" 138 | ) 139 | else: 140 | self._available = False 141 | self._state = None 142 | _LOGGER.warning("Couldn't retrieve light data") 143 | except Exception as e: 144 | self._available = False 145 | self._state = None 146 | _LOGGER.error(f"Error updating light state: {e}") 147 | -------------------------------------------------------------------------------- /custom_components/marshydro/api.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import json 3 | import time 4 | import logging 5 | import asyncio 6 | 7 | _LOGGER = logging.getLogger(__name__) 8 | 9 | 10 | class MarsHydroAPI: 11 | def __init__(self, email, password): 12 | self.email = email 13 | self.password = password 14 | self.token = None 15 | self.base_url = "https://api.lgledsolutions.com/api/android" 16 | self.api_lock = asyncio.Lock() 17 | self.last_login_time = 0 18 | self.login_interval = 300 # Minimum interval between logins in seconds 19 | self.device_id = None # Added device_id attribute to store dynamically 20 | 21 | async def login(self): 22 | """Authenticate and retrieve the token.""" 23 | async with self.api_lock: 24 | now = time.time() 25 | if self.token and (now - self.last_login_time < self.login_interval): 26 | _LOGGER.info("Token still valid, skipping login.") 27 | return 28 | 29 | system_data = self._generate_system_data() 30 | headers = {"systemData": system_data, "Content-Type": "application/json"} 31 | payload = { 32 | "email": self.email, 33 | "password": self.password, 34 | "loginMethod": "1", 35 | } 36 | 37 | async with aiohttp.ClientSession() as session: 38 | async with session.post( 39 | f"{self.base_url}/ulogin/mailLogin/v1", 40 | headers=headers, 41 | json=payload, 42 | ) as response: 43 | response.raise_for_status() 44 | data = await response.json() 45 | _LOGGER.info("API Login Response: %s", json.dumps(data, indent=2)) 46 | self.token = data["data"]["token"] 47 | self.last_login_time = now 48 | _LOGGER.info("Login erfolgreich, Token erhalten.") 49 | 50 | async def safe_api_call(self, func, *args, **kwargs): 51 | """Ensure thread-safe API calls.""" 52 | async with self.api_lock: 53 | return await func(*args, **kwargs) 54 | 55 | async def _ensure_token(self): 56 | """Ensure that the token is valid.""" 57 | if not self.token: 58 | await self.login() 59 | 60 | async def toggle_switch(self, is_close: bool, device_id: str): 61 | """Toggle the light or fan switch (on/off).""" 62 | await self._ensure_token() 63 | 64 | system_data = self._generate_system_data() 65 | headers = { 66 | "systemData": system_data, 67 | "Content-Type": "application/json", 68 | } 69 | payload = { 70 | "isClose": is_close, 71 | "deviceId": device_id, # Use the provided device_id 72 | "groupId": None, 73 | } 74 | 75 | _LOGGER.debug(f"Sending toggle switch payload: {json.dumps(payload, indent=2)}") 76 | 77 | async with aiohttp.ClientSession() as session: 78 | async with session.post( 79 | f"{self.base_url}/udm/lampSwitch/v1", headers=headers, json=payload 80 | ) as response: 81 | response_json = await response.json() 82 | _LOGGER.info( 83 | "API Toggle Switch Response: %s", 84 | json.dumps(response_json, indent=2), 85 | ) 86 | if response_json.get("code") == "102": # Handle token expiration 87 | _LOGGER.warning("Token expired, re-authenticating...") 88 | await self.login() 89 | return await self.toggle_switch(is_close, device_id) 90 | return response_json 91 | 92 | 93 | async def _process_device_list(self, product_type): 94 | """Retrieve device list for a given product type.""" 95 | await self._ensure_token() 96 | system_data = self._generate_system_data() 97 | headers = { 98 | "Accept-Encoding": "gzip", 99 | "Content-Type": "application/json", 100 | "Host": "api.lgledsolutions.com", 101 | "User-Agent": "Python/3.x", 102 | "systemData": system_data, 103 | } 104 | payload = {"currentPage": 0, "type": None, "productType": product_type} 105 | 106 | async with aiohttp.ClientSession() as session: 107 | async with session.post( 108 | f"{self.base_url}/udm/getDeviceList/v1", headers=headers, json=payload 109 | ) as response: 110 | response_json = await response.json() 111 | if response_json.get("code") == "000": 112 | device_list = response_json.get("data", {}).get("list", []) 113 | return device_list 114 | else: 115 | _LOGGER.error("Error in API response: %s", response_json.get("msg")) 116 | return [] 117 | 118 | async def get_lightdata(self): 119 | """Retrieve light data from the Mars Hydro API.""" 120 | device_list = await self._process_device_list("LIGHT") 121 | if device_list: 122 | device_data = device_list[0] 123 | self.device_id = device_data.get("id") # Store dynamic device_id 124 | return { 125 | "deviceName": device_data.get("deviceName"), 126 | "deviceLightRate": device_data.get("deviceLightRate"), 127 | "isClose": device_data.get("isClose"), 128 | "id": self.device_id, 129 | "deviceImage": device_data.get("deviceImg"), 130 | } 131 | else: 132 | _LOGGER.warning("No light devices found.") 133 | return None 134 | 135 | async def get_fandata(self): 136 | """Retrieve fan data from the Mars Hydro API.""" 137 | device_list = await self._process_device_list("WIND") 138 | if device_list: 139 | device_data = device_list[0] 140 | _LOGGER.debug("Fan data retrieved: %s", json.dumps(device_data, indent=2)) 141 | return { 142 | "deviceName": device_data.get("deviceName"), 143 | "deviceLightRate": device_data.get("deviceLightRate"), 144 | "humidity": device_data.get("humidity"), 145 | "temperature": device_data.get("temperature"), 146 | "speed": device_data.get("speed"), 147 | "isClose": device_data.get("isClose"), 148 | "id": device_data.get("id"), 149 | "deviceImage": device_data.get("deviceImg"), 150 | } 151 | else: 152 | _LOGGER.warning("No fan devices found.") 153 | return None 154 | 155 | async def set_brightness(self, brightness): 156 | """Set the brightness of the Mars Hydro light.""" 157 | await self._ensure_token() 158 | 159 | if not self.device_id: 160 | device_data = await self.get_lightdata() 161 | if device_data: 162 | self.device_id = device_data.get("id") 163 | 164 | system_data = self._generate_system_data() 165 | headers = { 166 | "Accept-Encoding": "gzip", 167 | "Content-Type": "application/json", 168 | "Host": "api.lgledsolutions.com", 169 | "systemData": system_data, 170 | "User-Agent": "Python/3.x", 171 | } 172 | payload = { 173 | "light": brightness, 174 | "deviceId": self.device_id, 175 | "groupId": None, 176 | } 177 | 178 | async with aiohttp.ClientSession() as session: 179 | async with session.post( 180 | f"{self.base_url}/udm/adjustLight/v1", headers=headers, json=payload 181 | ) as response: 182 | response_json = await response.json() 183 | _LOGGER.info( 184 | "API Set Brightness Response: %s", 185 | json.dumps(response_json, indent=2), 186 | ) 187 | return response_json 188 | 189 | async def set_fanspeed(self, speed, fan_device_id): 190 | """Set the speed of the Mars Hydro fan.""" 191 | await self._ensure_token() 192 | 193 | system_data = self._generate_system_data() 194 | headers = { 195 | "Accept-Encoding": "gzip", 196 | "Content-Type": "application/json", 197 | "Host": "api.lgledsolutions.com", 198 | "systemData": system_data, 199 | "User-Agent": "Python/3.x", 200 | } 201 | payload = { 202 | "light": speed, 203 | "deviceId": fan_device_id, 204 | "groupId": None, 205 | } 206 | 207 | _LOGGER.debug(f"Sending fan speed payload: {json.dumps(payload, indent=2)}") 208 | 209 | async with aiohttp.ClientSession() as session: 210 | async with session.post( 211 | f"{self.base_url}/udm/adjustLight/v1", headers=headers, json=payload 212 | ) as response: 213 | response_json = await response.json() 214 | _LOGGER.info( 215 | "API Set Fan Speed Response: %s", 216 | json.dumps(response_json, indent=2), 217 | ) 218 | return response_json 219 | 220 | def _generate_system_data(self): 221 | """Generate systemData payload with dynamic device_id.""" 222 | return json.dumps( 223 | { 224 | "reqId": int(time.time() * 1000), 225 | "appVersion": "1.2.0", 226 | "osType": "android", 227 | "osVersion": "14", 228 | "deviceType": "SM-S928C", 229 | "deviceId": self.device_id, 230 | "netType": "wifi", 231 | "wifiName": "123", 232 | "timestamp": int(time.time()), 233 | "token": self.token, 234 | "timezone": "Europe/Berlin", 235 | "language": "German", 236 | } 237 | ) 238 | -------------------------------------------------------------------------------- /custom_components/marshydro/sensor.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.sensor import SensorEntity 2 | from . import _LOGGER, DOMAIN 3 | 4 | 5 | async def async_setup_entry(hass, entry, async_add_entities): 6 | """Set up the Mars Hydro sensors.""" 7 | api = hass.data[DOMAIN][entry.entry_id].get("api") 8 | 9 | if api: 10 | # Create all sensors 11 | brightness_sensor = MarsHydroBrightnessSensor(api, entry.entry_id) 12 | fan_temperature_sensor = MarsHydroFanTemperatureSensor(api, entry.entry_id) 13 | fan_temperature_celsius_sensor = MarsHydroFanTemperatureCelsiusSensor( 14 | api, entry.entry_id 15 | ) 16 | fan_humidity_sensor = MarsHydroFanHumiditySensor(api, entry.entry_id) 17 | fan_speed_sensor = MarsHydroFanSpeedSensor(api, entry.entry_id) 18 | async_add_entities( 19 | [ 20 | brightness_sensor, 21 | fan_temperature_sensor, 22 | fan_temperature_celsius_sensor, 23 | fan_humidity_sensor, 24 | fan_speed_sensor, 25 | ], 26 | update_before_add=True, 27 | ) 28 | 29 | 30 | class MarsHydroBrightnessSensor(SensorEntity): 31 | """Representation of the Mars Hydro brightness sensor.""" 32 | 33 | def __init__(self, api, entry_id): 34 | self._api = api 35 | self._device_id = None 36 | self._device_name = None 37 | self._brightness = None 38 | self._available = True 39 | self._entry_id = entry_id 40 | 41 | @property 42 | def name(self): 43 | """Return the name of the sensor.""" 44 | if self._device_name and self._device_id: 45 | return f"{self._device_name} Brightness Sensor ({self._device_id})" 46 | elif self._device_name: 47 | return f"{self._device_name} Brightness Sensor" 48 | return "Mars Hydro Brightness Sensor" 49 | 50 | @property 51 | def native_value(self): 52 | """Return the brightness value.""" 53 | return self._brightness 54 | 55 | @property 56 | def available(self): 57 | """Return True if the sensor is available.""" 58 | return self._available 59 | 60 | @property 61 | def native_unit_of_measurement(self): 62 | """Return the unit of measurement.""" 63 | return "%" 64 | 65 | @property 66 | def unique_id(self): 67 | """Return a unique ID for the sensor.""" 68 | return ( 69 | f"{self._entry_id}_brightness_sensor_{self._device_id}" 70 | if self._device_id 71 | else f"{self._entry_id}_brightness_sensor" 72 | ) 73 | 74 | @property 75 | def device_info(self): 76 | """Return device information for linking with the device registry.""" 77 | if not self._device_id or not self._device_name: 78 | return None 79 | 80 | return { 81 | "identifiers": {(DOMAIN, self._device_id)}, 82 | "name": self._device_name, 83 | "manufacturer": "Mars Hydro", 84 | "model": "Mars Hydro Light", 85 | } 86 | 87 | async def async_update(self): 88 | """Update the sensor state.""" 89 | try: 90 | light_data = await self._api.safe_api_call(self._api.get_lightdata) 91 | if light_data: 92 | self._device_id = light_data["id"] 93 | self._device_name = light_data["deviceName"] 94 | self._brightness = light_data["deviceLightRate"] 95 | self._available = True 96 | _LOGGER.info( 97 | f"Brightness sensor updated: {self._brightness}% for {self._device_name}" 98 | ) 99 | else: 100 | self._available = False 101 | _LOGGER.warning("Could not update brightness sensor.") 102 | except Exception as e: 103 | self._available = False 104 | _LOGGER.error(f"Error updating brightness sensor: {e}") 105 | 106 | 107 | class MarsHydroFanTemperatureSensor(SensorEntity): 108 | """Representation of the Mars Hydro fan temperature sensor.""" 109 | 110 | def __init__(self, api, entry_id): 111 | self._api = api 112 | self._device_id = None 113 | self._device_name = None 114 | self._temperature = None 115 | self._available = True 116 | self._entry_id = entry_id 117 | 118 | @property 119 | def name(self): 120 | """Return the name of the fan temperature sensor.""" 121 | if self._device_name and self._device_id: 122 | return f"{self._device_name} Temperature Sensor ({self._device_id})" 123 | elif self._device_name: 124 | return f"{self._device_name} Temperature Sensor" 125 | return "Mars Hydro Fan Temperature Sensor" 126 | 127 | @property 128 | def native_value(self): 129 | """Return the fan's temperature.""" 130 | return self._temperature 131 | 132 | @property 133 | def available(self): 134 | """Return True if the sensor is available.""" 135 | return self._available 136 | 137 | @property 138 | def native_unit_of_measurement(self): 139 | """Return the unit of measurement.""" 140 | return "°F" 141 | 142 | @property 143 | def unique_id(self): 144 | """Return a unique ID for the fan temperature sensor.""" 145 | return ( 146 | f"{self._entry_id}_fan_temperature_sensor_{self._device_id}" 147 | if self._device_id 148 | else f"{self._entry_id}_fan_temperature_sensor" 149 | ) 150 | 151 | @property 152 | def device_info(self): 153 | """Return device information for linking with the fan device registry.""" 154 | if not self._device_id or not self._device_name: 155 | return None 156 | 157 | return { 158 | "identifiers": {(DOMAIN, self._device_id)}, 159 | "name": self._device_name, 160 | "manufacturer": "Mars Hydro", 161 | "model": "Mars Hydro Fan", 162 | } 163 | 164 | async def async_update(self): 165 | """Update the fan temperature sensor state.""" 166 | try: 167 | fan_data = await self._api.safe_api_call(self._api.get_fandata) 168 | if fan_data: 169 | self._device_id = fan_data["id"] 170 | self._device_name = fan_data["deviceName"] 171 | raw_temperature = fan_data["temperature"] 172 | 173 | try: 174 | self._temperature = float(raw_temperature) 175 | self._available = True 176 | _LOGGER.info( 177 | f"Fan temperature updated: {self._temperature}°F for {self._device_name}" 178 | ) 179 | except ValueError: 180 | _LOGGER.warning("Invalid temperature data: %s", raw_temperature) 181 | self._temperature = None 182 | self._available = False 183 | else: 184 | self._available = False 185 | self._temperature = None 186 | _LOGGER.warning("Could not update fan temperature sensor.") 187 | except Exception as e: 188 | self._available = False 189 | _LOGGER.error(f"Error updating fan temperature sensor: {e}") 190 | 191 | 192 | class MarsHydroFanTemperatureCelsiusSensor(SensorEntity): 193 | """Representation of the Mars Hydro fan temperature sensor in Celsius.""" 194 | 195 | def __init__(self, api, entry_id): 196 | self._api = api 197 | self._device_id = None 198 | self._device_name = None 199 | self._temperature_celsius = None 200 | self._available = True 201 | self._entry_id = entry_id 202 | 203 | @property 204 | def name(self): 205 | """Return the name of the fan temperature sensor (Celsius).""" 206 | if self._device_name and self._device_id: 207 | return ( 208 | f"{self._device_name} Temperature Sensor (Celsius) ({self._device_id})" 209 | ) 210 | elif self._device_name: 211 | return f"{self._device_name} Temperature Sensor (Celsius)" 212 | return "Mars Hydro Fan Temperature Sensor (Celsius)" 213 | 214 | @property 215 | def native_value(self): 216 | """Return the fan's temperature in Celsius.""" 217 | return self._temperature_celsius 218 | 219 | @property 220 | def available(self): 221 | """Return True if the sensor is available.""" 222 | return self._available 223 | 224 | @property 225 | def native_unit_of_measurement(self): 226 | """Return the unit of measurement.""" 227 | return "°C" 228 | 229 | @property 230 | def unique_id(self): 231 | """Return a unique ID for the fan temperature sensor in Celsius.""" 232 | return ( 233 | f"{self._entry_id}_fan_temperature_celsius_sensor_{self._device_id}" 234 | if self._device_id 235 | else f"{self._entry_id}_fan_temperature_celsius_sensor" 236 | ) 237 | 238 | @property 239 | def device_info(self): 240 | """Return device information for linking with the fan device registry.""" 241 | if not self._device_id or not self._device_name: 242 | return None 243 | 244 | return { 245 | "identifiers": {(DOMAIN, self._device_id)}, 246 | "name": self._device_name, 247 | "manufacturer": "Mars Hydro", 248 | "model": "Mars Hydro Fan", 249 | } 250 | 251 | async def async_update(self): 252 | """Update the fan temperature in Celsius.""" 253 | try: 254 | fan_data = await self._api.safe_api_call(self._api.get_fandata) 255 | if fan_data: 256 | self._device_id = fan_data["id"] 257 | self._device_name = fan_data["deviceName"] 258 | raw_temperature = fan_data["temperature"] 259 | 260 | try: 261 | self._temperature_celsius = round( 262 | (float(raw_temperature) - 32) * 5 / 9, 1 263 | ) 264 | self._available = True 265 | _LOGGER.info( 266 | f"Fan temperature updated: {self._temperature_celsius}°C for {self._device_name}" 267 | ) 268 | except ValueError: 269 | _LOGGER.warning("Invalid temperature data: %s", raw_temperature) 270 | self._temperature_celsius = None 271 | self._available = False 272 | else: 273 | self._available = False 274 | self._temperature_celsius = None 275 | _LOGGER.warning("Could not update fan temperature (Celsius) sensor.") 276 | except Exception as e: 277 | self._available = False 278 | _LOGGER.error(f"Error updating fan temperature (Celsius) sensor: {e}") 279 | 280 | 281 | class MarsHydroFanHumiditySensor(SensorEntity): 282 | """Representation of the Mars Hydro fan humidity sensor.""" 283 | 284 | def __init__(self, api, entry_id): 285 | self._api = api 286 | self._device_id = None 287 | self._device_name = None 288 | self._humidity = None 289 | self._available = True 290 | self._entry_id = entry_id 291 | 292 | @property 293 | def name(self): 294 | """Return the name of the fan humidity sensor.""" 295 | if self._device_name and self._device_id: 296 | return f"{self._device_name} Humidity Sensor ({self._device_id})" 297 | elif self._device_name: 298 | return f"{self._device_name} Humidity Sensor" 299 | return "Mars Hydro Fan Humidity Sensor" 300 | 301 | @property 302 | def native_value(self): 303 | """Return the fan's humidity.""" 304 | return self._humidity 305 | 306 | @property 307 | def available(self): 308 | """Return True if the sensor is available.""" 309 | return self._available 310 | 311 | @property 312 | def native_unit_of_measurement(self): 313 | """Return the unit of measurement.""" 314 | return "%" 315 | 316 | @property 317 | def unique_id(self): 318 | """Return a unique ID for the fan humidity sensor.""" 319 | return ( 320 | f"{self._entry_id}_fan_humidity_sensor_{self._device_id}" 321 | if self._device_id 322 | else f"{self._entry_id}_fan_humidity_sensor" 323 | ) 324 | 325 | @property 326 | def device_info(self): 327 | """Return device information for linking with the fan device registry.""" 328 | if not self._device_id or not self._device_name: 329 | return None 330 | 331 | return { 332 | "identifiers": {(DOMAIN, self._device_id)}, 333 | "name": self._device_name, 334 | "manufacturer": "Mars Hydro", 335 | "model": "Mars Hydro Fan", 336 | } 337 | 338 | async def async_update(self): 339 | """Update the fan humidity sensor state.""" 340 | try: 341 | fan_data = await self._api.safe_api_call(self._api.get_fandata) 342 | if fan_data: 343 | self._device_id = fan_data["id"] 344 | self._device_name = fan_data["deviceName"] 345 | raw_humidity = fan_data["humidity"] 346 | 347 | try: 348 | self._humidity = float(raw_humidity) 349 | self._available = True 350 | _LOGGER.info( 351 | f"Fan humidity updated: {self._humidity}% for {self._device_name}" 352 | ) 353 | except ValueError: 354 | _LOGGER.warning("Invalid humidity data: %s", raw_humidity) 355 | self._humidity = None 356 | self._available = False 357 | else: 358 | self._available = False 359 | self._humidity = None 360 | _LOGGER.warning("Could not update fan humidity sensor.") 361 | except Exception as e: 362 | self._available = False 363 | _LOGGER.error(f"Error updating fan humidity sensor: {e}") 364 | 365 | 366 | class MarsHydroFanSpeedSensor(SensorEntity): 367 | """Representation of the Mars Hydro fan speed sensor.""" 368 | 369 | def __init__(self, api, entry_id): 370 | self._api = api 371 | self._device_id = None 372 | self._device_name = None 373 | self._speed = None 374 | self._available = True 375 | self._entry_id = entry_id 376 | 377 | @property 378 | def name(self): 379 | """Return the name of the fan speed sensor.""" 380 | if self._device_name and self._device_id: 381 | return f"{self._device_name} Speed Sensor ({self._device_id})" 382 | elif self._device_name: 383 | return f"{self._device_name} Speed Sensor" 384 | return "Mars Hydro Fan Speed Sensor" 385 | 386 | @property 387 | def native_value(self): 388 | """Return the fan's speed.""" 389 | return self._speed 390 | 391 | @property 392 | def available(self): 393 | """Return True if the sensor is available.""" 394 | return self._available 395 | 396 | @property 397 | def native_unit_of_measurement(self): 398 | """Return the unit of measurement.""" 399 | return "RPM" 400 | 401 | @property 402 | def unique_id(self): 403 | """Return a unique ID for the fan speed sensor.""" 404 | return ( 405 | f"{self._entry_id}_fan_speed_sensor_{self._device_id}" 406 | if self._device_id 407 | else f"{self._entry_id}_fan_speed_sensor" 408 | ) 409 | 410 | @property 411 | def device_info(self): 412 | """Return device information for linking with the fan device registry.""" 413 | if not self._device_id or not self._device_name: 414 | return None 415 | 416 | return { 417 | "identifiers": {(DOMAIN, self._device_id)}, 418 | "name": self._device_name, 419 | "manufacturer": "Mars Hydro", 420 | "model": "Mars Hydro Fan", 421 | } 422 | 423 | async def async_update(self): 424 | """Update the fan speed sensor state.""" 425 | try: 426 | fan_data = await self._api.safe_api_call(self._api.get_fandata) 427 | if fan_data: 428 | self._device_id = fan_data["id"] 429 | self._device_name = fan_data["deviceName"] 430 | raw_speed = fan_data.get("speed") 431 | 432 | try: 433 | self._speed = int(raw_speed) 434 | self._available = True 435 | _LOGGER.info( 436 | f"Fan speed updated: {self._speed} RPM for {self._device_name}" 437 | ) 438 | except ValueError: 439 | _LOGGER.warning("Invalid speed data: %s", raw_speed) 440 | self._speed = None 441 | self._available = False 442 | else: 443 | self._available = False 444 | self._speed = None 445 | _LOGGER.warning("Could not update fan speed sensor.") 446 | except Exception as e: 447 | self._available = False 448 | _LOGGER.error(f"Error updating fan speed sensor: {e}") 449 | --------------------------------------------------------------------------------