diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 0000000..c70f1f5 --- /dev/null +++ b/.devcontainer.json @@ -0,0 +1,42 @@ +{ + "name": "ludeeus/integration_blueprint", + "image": "mcr.microsoft.com/devcontainers/python:3.11-bullseye", + "postCreateCommand": "scripts/setup", + "forwardPorts": [ + 8123 + ], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } + } + }, + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/rust:1": {} + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0c8b08c..04afab9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,6 +10,9 @@ updates: schedule: interval: "daily" open-pull-requests-limit: 100 + ignore: + # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json + - dependency-name: "homeassistant" - package-ecosystem: "github-actions" directory: "/" diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml index 2f750eb..940b673 100644 --- a/.github/workflows/hassfest.yaml +++ b/.github/workflows/hassfest.yaml @@ -10,5 +10,5 @@ jobs: validate: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v4" + - uses: "actions/checkout@v4.1.1" - uses: home-assistant/actions/hassfest@master \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..09b32b9 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,33 @@ +name: "Lint" + +on: + push: + pull_request: + +jobs: + ruff: + name: "Ruff" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.1" + + - name: "Set up Python" + uses: actions/setup-python@v5.0.0 + with: + python-version: "3.11" + cache: "pip" + + - name: "Install requirements" + run: python3 -m pip install -r requirements.txt + + - name: "Format" + run: python3 -m ruff format . + + - name: "Check" + run: python3 -m ruff check . + + - name: "Auto Commit" + uses: stefanzweifel/git-auto-commit-action@v5.0.0 + with: + commit_message: 'style fixes by ruff' \ No newline at end of file diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index db6b2ac..63110b0 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -10,7 +10,7 @@ jobs: validate: runs-on: "ubuntu-latest" steps: - - uses: "actions/checkout@v4" + - uses: "actions/checkout@v4.1.1" - name: HACS validation uses: "hacs/action@main" with: diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..efc104e --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,67 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml + +target-version = "py310" + +select = [ + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "C", # complexity + "D", # docstrings + "E", # pycodestyle + "F", # pyflakes/autoflake + "ICN001", # import concentions; {name} should be imported as {asname} + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Merge with-statements that use the same scope + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of an if block + "T20", # flake8-print + "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D404", # First word of the docstring should not be This + "D406", # Section name should end with a newline + "D407", # Section name underlining + "D411", # Missing blank line before section + "E501", # line too long + "E731", # do not assign a lambda expression, use a def + + # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q000", + "Q001", + "Q002", + "Q003", + "COM812", + "COM819", + "ISC001", + "ISC002", + + # Disabled because ruff does not understand type of __all__ generated by a function + "PLE0605", +] + +[flake8-pytest-style] +fixture-parentheses = false + +[pyupgrade] +keep-runtime-typing = true + +[mccabe] +max-complexity = 25 \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..3aa1c50 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,11 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant on port 8123", + "type": "shell", + "command": "scripts/develop", + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..153d903 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +mail@pirateweather.net. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b251922 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contribution guidelines + +Contributing to this project should be as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features + +## Github is used for everything + +Github is used to host code, to track issues and feature requests, as well as accept pull requests. + +Pull requests are the best way to propose changes to the codebase. + +1. Fork the repo and create your branch from `main`. +2. If you've changed something, update the documentation. +3. Make sure your code lints (using `scripts/lint`). +4. Test you contribution. +5. Issue that pull request! + +## Any contributions you make will be under the Apache-2.0 License + +In short, when you submit code changes, your submissions are understood to be under the same [Apache-2.0](https://github.com/Pirate-Weather/pirate-weather-ha?tab=Apache-2.0-1-ov-file#readme) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using Github's [issues](https://github.com/Pirate-Weather/pirate-weather-ha/issues) + +GitHub issues are used to track public bugs. +Report a bug by [opening a new issue](https://github.com/Pirate-Weather/pirate-weather-ha/issues/new/choose); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People *love* thorough bug reports. I'm not even kidding. + +## Use a Consistent Coding Style + +Use [Ruff formater](https://docs.astral.sh/ruff/formatter/) to make sure the code follows the style. + +## Test your code modification + +This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint). + +It comes with development environment in a container, easy to launch +if you use Visual Studio Code. With this container you will have a stand alone +Home Assistant instance running and already configured with the included +[`configuration.yaml`](./config/configuration.yaml) +file. + +## License + +By contributing, you agree that your contributions will be licensed under its Apache-2.0 License. diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 0000000..539bb19 --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,8 @@ +# https://www.home-assistant.io/integrations/default_config/ +default_config: + +# https://www.home-assistant.io/integrations/logger/ +logger: + default: info + logs: + custom_components.pirateweather: debug diff --git a/custom_components/pirateweather/__init__.py b/custom_components/pirateweather/__init__.py index eb4d841..ce8ebc0 100644 --- a/custom_components/pirateweather/__init__.py +++ b/custom_components/pirateweather/__init__.py @@ -4,14 +4,10 @@ import logging from typing import Any -import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_registry import EntityRegistry from datetime import timedelta -import forecastio -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -26,32 +22,23 @@ from homeassistant.core import HomeAssistant from .const import ( - CONF_LANGUAGE, - CONFIG_FLOW_VERSION, DOMAIN, - DEFAULT_FORECAST_MODE, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, - FORECAST_MODES, PLATFORMS, UPDATE_LISTENER, CONF_UNITS, - DEFAULT_UNITS, - DEFAULT_NAME, - FORECASTS_HOURLY, - FORECASTS_DAILY, PW_PLATFORMS, - PW_PLATFORM, - PW_PREVPLATFORM, + PW_PLATFORM, PW_ROUND, ) +# from .weather_update_coordinator import WeatherUpdateCoordinator, DarkSkyData +from .weather_update_coordinator import WeatherUpdateCoordinator + CONF_FORECAST = "forecast" CONF_HOURLY_FORECAST = "hourly_forecast" -#from .weather_update_coordinator import WeatherUpdateCoordinator, DarkSkyData -from .weather_update_coordinator import WeatherUpdateCoordinator - _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Powered by Pirate Weather" @@ -63,7 +50,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: latitude = entry.data.get(CONF_LATITUDE, hass.config.latitude) longitude = entry.data.get(CONF_LONGITUDE, hass.config.longitude) forecast_mode = _get_config_value(entry, CONF_MODE) - language = _get_config_value(entry, CONF_LANGUAGE) conditions = _get_config_value(entry, CONF_MONITORED_CONDITIONS) units = _get_config_value(entry, CONF_UNITS) forecast_days = _get_config_value(entry, CONF_FORECAST) @@ -71,45 +57,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: pw_entity_platform = _get_config_value(entry, PW_PLATFORM) pw_entity_rounding = _get_config_value(entry, PW_ROUND) pw_scan_Int = entry.data[CONF_SCAN_INTERVAL] - - + # Extract list of int from forecast days/ hours string if present - #_LOGGER.warning('forecast_days_type: ' + str(type(forecast_days))) - - #_LOGGER.warning(forecast_days) - if type(forecast_days) == str: - # If empty, set to none - if forecast_days == "" or forecast_days == "None": - forecast_days = None - else: - if forecast_days[0] == '[': - forecast_days = forecast_days[1:-1].split(",") - else: - forecast_days = forecast_days.split(",") - forecast_days = [int(i) for i in forecast_days] - - if type(forecast_hours) == str: - # If empty, set to none - if forecast_hours == "" or forecast_hours == "None": - forecast_hours = None - else: - if forecast_hours[0] == '[': - forecast_hours = forecast_hours[1:-1].split(",") - else: - forecast_hours = forecast_hours.split(",") - forecast_hours = [int(i) for i in forecast_hours] - - unique_location = (f"pw-{latitude}-{longitude}") - + # _LOGGER.warning('forecast_days_type: ' + str(type(forecast_days))) + + # _LOGGER.warning(forecast_days) + if isinstance(forecast_days, str): + # If empty, set to none + if forecast_days == "" or forecast_days == "None": + forecast_days = None + else: + if forecast_days[0] == "[": + forecast_days = forecast_days[1:-1].split(",") + else: + forecast_days = forecast_days.split(",") + forecast_days = [int(i) for i in forecast_days] + + if isinstance(forecast_hours, str): + # If empty, set to none + if forecast_hours == "" or forecast_hours == "None": + forecast_hours = None + else: + if forecast_hours[0] == "[": + forecast_hours = forecast_hours[1:-1].split(",") + else: + forecast_hours = forecast_hours.split(",") + forecast_hours = [int(i) for i in forecast_hours] + + unique_location = f"pw-{latitude}-{longitude}" + hass.data.setdefault(DOMAIN, {}) # Create and link weather WeatherUpdateCoordinator - weather_coordinator = WeatherUpdateCoordinator(api_key, latitude, longitude, timedelta(seconds=pw_scan_Int), hass) - hass.data[DOMAIN][unique_location] = weather_coordinator + weather_coordinator = WeatherUpdateCoordinator( + api_key, latitude, longitude, timedelta(seconds=pw_scan_Int), hass + ) + hass.data[DOMAIN][unique_location] = weather_coordinator - #await weather_coordinator.async_refresh() + # await weather_coordinator.async_refresh() await weather_coordinator.async_config_entry_first_refresh() - - + hass.data[DOMAIN][entry.entry_id] = { ENTRY_NAME: name, ENTRY_WEATHER_COORDINATOR: weather_coordinator, @@ -127,16 +113,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } # If both platforms - if (PW_PLATFORMS[0] in pw_entity_platform) and (PW_PLATFORMS[1] in pw_entity_platform): - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # If only sensor + if (PW_PLATFORMS[0] in pw_entity_platform) and ( + PW_PLATFORMS[1] in pw_entity_platform + ): + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # If only sensor elif PW_PLATFORMS[0] in pw_entity_platform: - await hass.config_entries.async_forward_entry_setup(entry, PLATFORMS[0]) + await hass.config_entries.async_forward_entry_setup(entry, PLATFORMS[0]) # If only weather elif PW_PLATFORMS[1] in pw_entity_platform: - await hass.config_entries.async_forward_entry_setup(entry, PLATFORMS[1]) - - + await hass.config_entries.async_forward_entry_setup(entry, PLATFORMS[1]) + update_listener = entry.add_update_listener(async_update_options) hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER] = update_listener return True @@ -146,33 +133,36 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - + pw_entity_prevplatform = hass.data[DOMAIN][entry.entry_id][PW_PLATFORM] - # If both - if (PW_PLATFORMS[0] in pw_entity_prevplatform) and (PW_PLATFORMS[1] in pw_entity_prevplatform): - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if (PW_PLATFORMS[0] in pw_entity_prevplatform) and ( + PW_PLATFORMS[1] in pw_entity_prevplatform + ): + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) # If only sensor elif PW_PLATFORMS[0] in pw_entity_prevplatform: - unload_ok = await hass.config_entries.async_unload_platforms(entry, [PLATFORMS[0]]) + unload_ok = await hass.config_entries.async_unload_platforms( + entry, [PLATFORMS[0]] + ) # If only Weather elif PW_PLATFORMS[1] in pw_entity_prevplatform: - unload_ok = await hass.config_entries.async_unload_platforms(entry, [PLATFORMS[1]]) - - _LOGGER.info('Unloading Pirate Weather') - + unload_ok = await hass.config_entries.async_unload_platforms( + entry, [PLATFORMS[1]] + ) + + _LOGGER.info("Unloading Pirate Weather") + if unload_ok: update_listener = hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER] update_listener() - + hass.data[DOMAIN].pop(entry.entry_id) - - + return unload_ok diff --git a/custom_components/pirateweather/config_flow.py b/custom_components/pirateweather/config_flow.py index d949667..40a047f 100644 --- a/custom_components/pirateweather/config_flow.py +++ b/custom_components/pirateweather/config_flow.py @@ -3,9 +3,6 @@ import logging from datetime import timedelta -import forecastio -from forecastio.models import Forecast -import json import aiohttp from homeassistant import config_entries @@ -33,8 +30,6 @@ LANGUAGES, CONF_UNITS, DEFAULT_UNITS, - FORECASTS_DAILY, - FORECASTS_HOURLY, ALL_CONDITIONS, PW_PLATFORMS, PW_PLATFORM, @@ -65,95 +60,94 @@ async def async_step_user(self, user_input=None): errors = {} schema = vol.Schema( - { - vol.Required(CONF_API_KEY): str, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, - vol.Optional( - CONF_LATITUDE, default=self.hass.config.latitude - ): cv.latitude, - vol.Optional( - CONF_LONGITUDE, default=self.hass.config.longitude - ): cv.longitude, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): int, - vol.Required(PW_PLATFORM, default=[PW_PLATFORMS[1]]): cv.multi_select( - PW_PLATFORMS - ), - vol.Required(CONF_MODE, default=DEFAULT_FORECAST_MODE): vol.In( - FORECAST_MODES - ), - vol.Required(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In( - LANGUAGES - ), - vol.Optional(CONF_FORECAST, default=""): str, - vol.Optional(CONF_HOURLY_FORECAST, default=""): str, - vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): cv.multi_select( - ALL_CONDITIONS - ), - vol.Optional(PW_ROUND, default="No"): vol.In(["Yes", "No"] - ), - vol.Optional(CONF_UNITS, default=DEFAULT_UNITS): vol.In(["si", "us", "ca", "uk"] - ), - } - ) - - + { + vol.Required(CONF_API_KEY): str, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, + vol.Optional( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Optional( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): int, + vol.Required(PW_PLATFORM, default=[PW_PLATFORMS[1]]): cv.multi_select( + PW_PLATFORMS + ), + vol.Required(CONF_MODE, default=DEFAULT_FORECAST_MODE): vol.In( + FORECAST_MODES + ), + vol.Required(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In( + LANGUAGES + ), + vol.Optional(CONF_FORECAST, default=""): str, + vol.Optional(CONF_HOURLY_FORECAST, default=""): str, + vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): cv.multi_select( + ALL_CONDITIONS + ), + vol.Optional(PW_ROUND, default="No"): vol.In(["Yes", "No"]), + vol.Optional(CONF_UNITS, default=DEFAULT_UNITS): vol.In( + ["si", "us", "ca", "uk"] + ), + } + ) + if user_input is not None: latitude = user_input[CONF_LATITUDE] longitude = user_input[CONF_LONGITUDE] - forecastDays = user_input[CONF_FORECAST] - forecastHours = user_input[CONF_HOURLY_FORECAST] forecastMode = user_input[CONF_MODE] forecastPlatform = user_input[PW_PLATFORM] entityNamee = user_input[CONF_NAME] - - + # Convert scan interval to timedelta if isinstance(user_input[CONF_SCAN_INTERVAL], str): - user_input[CONF_SCAN_INTERVAL] = cv.time_period_str(user_input[CONF_SCAN_INTERVAL]) - - + user_input[CONF_SCAN_INTERVAL] = cv.time_period_str( + user_input[CONF_SCAN_INTERVAL] + ) + # Convert scan interval to number of seconds if isinstance(user_input[CONF_SCAN_INTERVAL], timedelta): - user_input[CONF_SCAN_INTERVAL] = user_input[CONF_SCAN_INTERVAL].total_seconds() - + user_input[CONF_SCAN_INTERVAL] = user_input[ + CONF_SCAN_INTERVAL + ].total_seconds() + + # Unique value includes the location and forcastHours/ forecastDays to seperate WeatherEntity/ Sensor + # await self.async_set_unique_id(f"pw-{latitude}-{longitude}-{forecastDays}-{forecastHours}-{forecastMode}-{entityNamee}") + await self.async_set_unique_id( + f"pw-{latitude}-{longitude}-{forecastPlatform}-{forecastMode}-{entityNamee}" + ) - # Unique value includes the location and forcastHours/ forecastDays to seperate WeatherEntity/ Sensor -# await self.async_set_unique_id(f"pw-{latitude}-{longitude}-{forecastDays}-{forecastHours}-{forecastMode}-{entityNamee}") - await self.async_set_unique_id(f"pw-{latitude}-{longitude}-{forecastPlatform}-{forecastMode}-{entityNamee}") - self._abort_if_unique_id_configured() - try: - api_status = await _is_pw_api_online( - self.hass, user_input[CONF_API_KEY], latitude, longitude - ) - - if api_status == 403: - _LOGGER.warning("Pirate Weather Setup Error: Invalid API Key, Ensure that the subscribe button is clicked for this endpoint: https://pirateweather.net/apis/hv9nrw1tjg/Beta") - errors["base"] = "Invalid API Key, Ensure that the subscribe button is clicked for this endpoint: https://pirateweather.net/apis/hv9nrw1tjg/Beta" - - except: - _LOGGER.warning("Pirate Weather Setup Error: HTTP Error: " + api_status) - errors["base"] = "API Error: " + api_status - + api_status = await _is_pw_api_online( + self.hass, user_input[CONF_API_KEY], latitude, longitude + ) + + if api_status == 403: + _LOGGER.warning( + "Pirate Weather Setup Error: Invalid API Key, Ensure that the subscribe button is clicked for this endpoint: https://pirateweather.net/apis/hv9nrw1tjg/Beta" + ) + errors[ + "base" + ] = "Invalid API Key, Ensure that the subscribe button is clicked for this endpoint: https://pirateweather.net/apis/hv9nrw1tjg/Beta" + + except Exception: + _LOGGER.warning("Pirate Weather Setup Error: HTTP Error: " + api_status) + errors["base"] = "API Error: " + api_status + if not errors: return self.async_create_entry( title=user_input[CONF_NAME], data=user_input ) else: _LOGGER.warning(errors) - - - return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def async_step_import(self, import_input=None): """Set the config entry up from yaml.""" config = import_input.copy() - + if CONF_NAME not in config: config[CONF_NAME] = DEFAULT_NAME if CONF_LATITUDE not in config: @@ -163,25 +157,25 @@ async def async_step_import(self, import_input=None): if CONF_MODE not in config: config[CONF_MODE] = DEFAULT_FORECAST_MODE if CONF_LANGUAGE not in config: - config[CONF_LANGUAGE] = DEFAULT_LANGUAGE + config[CONF_LANGUAGE] = DEFAULT_LANGUAGE if CONF_UNITS not in config: config[CONF_UNITS] = DEFAULT_UNITS if CONF_MONITORED_CONDITIONS not in config: - config[CONF_MONITORED_CONDITIONS] = [] + config[CONF_MONITORED_CONDITIONS] = [] if CONF_FORECAST not in config: - config[CONF_FORECAST] = "" + config[CONF_FORECAST] = "" if CONF_HOURLY_FORECAST not in config: config[CONF_HOURLY_FORECAST] = "" if CONF_API_KEY not in config: - config[CONF_API_KEY] = None + config[CONF_API_KEY] = None if PW_PLATFORM not in config: - config[PW_PLATFORM] = None + config[PW_PLATFORM] = None if PW_PREVPLATFORM not in config: - config[PW_PREVPLATFORM] = None + config[PW_PREVPLATFORM] = None if PW_ROUND not in config: - config[PW_ROUND] = "No" + config[PW_ROUND] = "No" if CONF_SCAN_INTERVAL not in config: - config[CONF_SCAN_INTERVAL] = DEFAULT_SCAN_INTERVAL + config[CONF_SCAN_INTERVAL] = DEFAULT_SCAN_INTERVAL return await self.async_step_user(config) @@ -195,110 +189,124 @@ def __init__(self, config_entry): async def async_step_init(self, user_input=None): """Manage the options.""" if user_input is not None: - entry = self.config_entry - - #if self.config_entry.options: + # if self.config_entry.options: # user_input[PW_PREVPLATFORM] = self.config_entry.options[PW_PLATFORM] - #else: - #user_input[PW_PREVPLATFORM] = self.hass.data[DOMAIN][entry.entry_id][PW_PLATFORM] - #self.hass.data[DOMAIN][entry.entry_id][PW_PREVPLATFORM] = self.hass.data[DOMAIN][entry.entry_id][PW_PLATFORM] - #user_input[PW_PREVPLATFORM] = self.hass.data[DOMAIN][entry.entry_id][PW_PLATFORM] - #_LOGGER.warning('async_step_init_Options') + # else: + # user_input[PW_PREVPLATFORM] = self.hass.data[DOMAIN][entry.entry_id][PW_PLATFORM] + # self.hass.data[DOMAIN][entry.entry_id][PW_PREVPLATFORM] = self.hass.data[DOMAIN][entry.entry_id][PW_PLATFORM] + # user_input[PW_PREVPLATFORM] = self.hass.data[DOMAIN][entry.entry_id][PW_PLATFORM] + # _LOGGER.warning('async_step_init_Options') return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) - + return self.async_show_form( step_id="init", data_schema=vol.Schema( - { - vol.Optional( - CONF_NAME, - default=self.config_entry.options.get( - CONF_NAME, - self.config_entry.data.get(CONF_NAME, DEFAULT_NAME), - ), - ): str, - vol.Optional( - CONF_LATITUDE, - default=self.config_entry.options.get( - CONF_LATITUDE, - self.config_entry.data.get(CONF_LATITUDE, self.hass.config.latitude), - ), - ): cv.latitude, - vol.Optional( - CONF_LONGITUDE, - default=self.config_entry.options.get( - CONF_LONGITUDE, - self.config_entry.data.get(CONF_LONGITUDE, self.hass.config.longitude), - ), - ): cv.longitude, - vol.Required( - PW_PLATFORM, - default=self.config_entry.options.get( + { + vol.Optional( + CONF_NAME, + default=self.config_entry.options.get( + CONF_NAME, + self.config_entry.data.get(CONF_NAME, DEFAULT_NAME), + ), + ): str, + vol.Optional( + CONF_LATITUDE, + default=self.config_entry.options.get( + CONF_LATITUDE, + self.config_entry.data.get( + CONF_LATITUDE, self.hass.config.latitude + ), + ), + ): cv.latitude, + vol.Optional( + CONF_LONGITUDE, + default=self.config_entry.options.get( + CONF_LONGITUDE, + self.config_entry.data.get( + CONF_LONGITUDE, self.hass.config.longitude + ), + ), + ): cv.longitude, + vol.Required( PW_PLATFORM, - self.config_entry.data.get(PW_PLATFORM, []), - ), - ): cv.multi_select(PW_PLATFORMS), - vol.Optional( - CONF_MODE, - default=self.config_entry.options.get( + default=self.config_entry.options.get( + PW_PLATFORM, + self.config_entry.data.get(PW_PLATFORM, []), + ), + ): cv.multi_select(PW_PLATFORMS), + vol.Optional( CONF_MODE, - self.config_entry.data.get(CONF_MODE, DEFAULT_FORECAST_MODE), - ), - ): vol.In(FORECAST_MODES), - vol.Optional( - CONF_LANGUAGE, - default=self.config_entry.options.get( + default=self.config_entry.options.get( + CONF_MODE, + self.config_entry.data.get( + CONF_MODE, DEFAULT_FORECAST_MODE + ), + ), + ): vol.In(FORECAST_MODES), + vol.Optional( CONF_LANGUAGE, - self.config_entry.data.get(CONF_LANGUAGE, DEFAULT_LANGUAGE), - ), - ): vol.In(LANGUAGES), - vol.Optional( - CONF_FORECAST, - default=str(self.config_entry.options.get( - CONF_FORECAST, - self.config_entry.data.get(CONF_FORECAST, "")), - ), - ): str, - vol.Optional( - CONF_HOURLY_FORECAST, - default=str(self.config_entry.options.get( - CONF_HOURLY_FORECAST, - self.config_entry.data.get(CONF_HOURLY_FORECAST, "")), - ), - ): str, - vol.Optional( - CONF_MONITORED_CONDITIONS, - default=self.config_entry.options.get( - CONF_MONITORED_CONDITIONS, - self.config_entry.data.get(CONF_MONITORED_CONDITIONS, []), - ), - ): cv.multi_select(ALL_CONDITIONS), - vol.Optional( - CONF_UNITS, - default=self.config_entry.options.get( - CONF_UNITS, - self.config_entry.data.get(CONF_UNITS, DEFAULT_UNITS), - ), - ): vol.In(["si", "us", "ca", "uk"]), - vol.Optional( - PW_ROUND, - default=self.config_entry.options.get( - PW_ROUND, - self.config_entry.data.get(PW_ROUND, "No"), - ), - ): vol.In(["Yes", "No"]), - } + default=self.config_entry.options.get( + CONF_LANGUAGE, + self.config_entry.data.get(CONF_LANGUAGE, DEFAULT_LANGUAGE), + ), + ): vol.In(LANGUAGES), + vol.Optional( + CONF_FORECAST, + default=str( + self.config_entry.options.get( + CONF_FORECAST, + self.config_entry.data.get(CONF_FORECAST, ""), + ), + ), + ): str, + vol.Optional( + CONF_HOURLY_FORECAST, + default=str( + self.config_entry.options.get( + CONF_HOURLY_FORECAST, + self.config_entry.data.get(CONF_HOURLY_FORECAST, ""), + ), + ), + ): str, + vol.Optional( + CONF_MONITORED_CONDITIONS, + default=self.config_entry.options.get( + CONF_MONITORED_CONDITIONS, + self.config_entry.data.get(CONF_MONITORED_CONDITIONS, []), + ), + ): cv.multi_select(ALL_CONDITIONS), + vol.Optional( + CONF_UNITS, + default=self.config_entry.options.get( + CONF_UNITS, + self.config_entry.data.get(CONF_UNITS, DEFAULT_UNITS), + ), + ): vol.In(["si", "us", "ca", "uk"]), + vol.Optional( + PW_ROUND, + default=self.config_entry.options.get( + PW_ROUND, + self.config_entry.data.get(PW_ROUND, "No"), + ), + ): vol.In(["Yes", "No"]), + } ), ) + async def _is_pw_api_online(hass, api_key, lat, lon): - forecastString = "https://api.pirateweather.net/forecast/" + api_key + "/" + str(lat) + "," + str(lon) - - async with aiohttp.ClientSession(raise_for_status=False) as session: - async with session.get(forecastString) as resp: - resptext = await resp.text() - jsonText = json.loads(resptext) - headers = resp.headers - status = resp.status - - return status + forecastString = ( + "https://api.pirateweather.net/forecast/" + + api_key + + "/" + + str(lat) + + "," + + str(lon) + ) + + async with aiohttp.ClientSession(raise_for_status=False) as session, session.get( + forecastString + ) as resp: + status = resp.status + + return status diff --git a/custom_components/pirateweather/const.py b/custom_components/pirateweather/const.py index 2343240..af1753e 100644 --- a/custom_components/pirateweather/const.py +++ b/custom_components/pirateweather/const.py @@ -1,6 +1,5 @@ """Consts for the OpenWeatherMap.""" from __future__ import annotations -from datetime import timedelta from homeassistant.components.sensor import ( SensorDeviceClass, @@ -8,20 +7,6 @@ SensorStateClass, ) from homeassistant.components.weather import ( - ATTR_CONDITION_CLOUDY, - ATTR_CONDITION_EXCEPTIONAL, - ATTR_CONDITION_FOG, - ATTR_CONDITION_HAIL, - ATTR_CONDITION_LIGHTNING, - ATTR_CONDITION_LIGHTNING_RAINY, - ATTR_CONDITION_PARTLYCLOUDY, - ATTR_CONDITION_POURING, - ATTR_CONDITION_RAINY, - ATTR_CONDITION_SNOWY, - ATTR_CONDITION_SNOWY_RAINY, - ATTR_CONDITION_SUNNY, - ATTR_CONDITION_WINDY, - ATTR_CONDITION_WINDY_VARIANT, ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRECIPITATION_PROBABILITY, @@ -76,7 +61,7 @@ PW_PLATFORMS = ["Sensor", "Weather"] PW_PLATFORM = "pw_platform" PW_PREVPLATFORM = "pw_prevplatform" -PW_ROUND = "pw_round" +PW_ROUND = "pw_round" ATTR_FORECAST_CLOUD_COVERAGE = "cloud_coverage" ATTR_FORECAST_HUMIDITY = "humidity" @@ -87,49 +72,50 @@ FORECAST_MODES = [ FORECAST_MODE_HOURLY, - FORECAST_MODE_DAILY, - ] - - + FORECAST_MODE_DAILY, +] + + DEFAULT_FORECAST_MODE = FORECAST_MODE_DAILY FORECASTS_HOURLY = "forecasts_hourly" FORECASTS_DAILY = "forecasts_daily" -ALL_CONDITIONS = {'summary': 'Summary', - 'icon': 'Icon', - 'precip_type': 'Precipitation Type', - 'precip_intensity': 'Precipitation Intensity', - 'precip_probability': 'Precipitation Probability', - 'precip_accumulation': 'Precipitation Accumulation', - 'temperature': 'Temperature', - 'apparent_temperature': 'Apparent Temperature', - 'dew_point': 'Dew Point', - 'humidity': 'Humidity', - 'wind_speed': 'Wind Speed', - 'wind_gust': 'Wind Gust', - 'wind_bearing': 'Wind Bearing', - 'cloud_cover': 'Cloud Cover', - 'pressure': 'Pressure', - 'visibility': 'Visibility', - 'ozone': 'Ozone', - 'minutely_summary': 'Minutely Summary', - 'hourly_summary': 'Hourly Summary', - 'daily_summary': 'Daily Summary', - 'temperature_high': 'Temperature High', - 'temperature_low': 'Temperature Low', - 'apparent_temperature_high': 'Apparent Temperature High', - 'apparent_temperature_low': 'Apparent Temperature Low', - 'precip_intensity_max': 'Precip Intensity Max', - 'uv_index': 'UV Index', - 'moon_phase': 'Moon Phase', - 'sunrise_time': 'Sunrise Time', - 'sunset_time': 'Sunset Time', - 'nearest_storm_distance': 'Nearest Storm Distance', - 'nearest_storm_bearing': 'Nearest Storm Bearing', - 'alerts': 'Alerts', - 'time':'Time' - } +ALL_CONDITIONS = { + "summary": "Summary", + "icon": "Icon", + "precip_type": "Precipitation Type", + "precip_intensity": "Precipitation Intensity", + "precip_probability": "Precipitation Probability", + "precip_accumulation": "Precipitation Accumulation", + "temperature": "Temperature", + "apparent_temperature": "Apparent Temperature", + "dew_point": "Dew Point", + "humidity": "Humidity", + "wind_speed": "Wind Speed", + "wind_gust": "Wind Gust", + "wind_bearing": "Wind Bearing", + "cloud_cover": "Cloud Cover", + "pressure": "Pressure", + "visibility": "Visibility", + "ozone": "Ozone", + "minutely_summary": "Minutely Summary", + "hourly_summary": "Hourly Summary", + "daily_summary": "Daily Summary", + "temperature_high": "Temperature High", + "temperature_low": "Temperature Low", + "apparent_temperature_high": "Apparent Temperature High", + "apparent_temperature_low": "Apparent Temperature Low", + "precip_intensity_max": "Precip Intensity Max", + "uv_index": "UV Index", + "moon_phase": "Moon Phase", + "sunrise_time": "Sunrise Time", + "sunset_time": "Sunset Time", + "nearest_storm_distance": "Nearest Storm Distance", + "nearest_storm_bearing": "Nearest Storm Bearing", + "alerts": "Alerts", + "time": "Time", +} LANGUAGES = [ "af", @@ -325,4 +311,4 @@ name="Cloud coverage", native_unit_of_measurement=PERCENTAGE, ), -) \ No newline at end of file +) diff --git a/custom_components/pirateweather/manifest.json b/custom_components/pirateweather/manifest.json index f1a9b64..6db431c 100644 --- a/custom_components/pirateweather/manifest.json +++ b/custom_components/pirateweather/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/alexander0042/pirate-weather-ha/issues", "requirements": ["python-forecastio==1.4.0"], - "version": "1.3.7" + "version": "1.4.0" } diff --git a/custom_components/pirateweather/sensor.py b/custom_components/pirateweather/sensor.py index 3b12ec0..5172d1d 100644 --- a/custom_components/pirateweather/sensor.py +++ b/custom_components/pirateweather/sensor.py @@ -1,39 +1,27 @@ """Support for PirateWeather (Dark Sky Compatable weather service.""" -from datetime import timedelta import logging from dataclasses import dataclass, field -import forecastio -from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout import voluptuous as vol import homeassistant.helpers.config_validation as cv import homeassistant.helpers.template as template_helper from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, - SensorStateClass + SensorStateClass, ) from typing import Literal, NamedTuple from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.typing import DiscoveryInfoType -from homeassistant.util import dt as dt_util -from homeassistant.util.unit_system import METRIC_SYSTEM - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, -) from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -55,25 +43,9 @@ ) from .const import ( - CONF_LANGUAGE, - CONFIG_FLOW_VERSION, - DEFAULT_FORECAST_MODE, - DEFAULT_LANGUAGE, - DEFAULT_NAME, DEFAULT_SCAN_INTERVAL, DOMAIN, - FORECAST_MODES, - LANGUAGES, - CONF_UNITS, - DEFAULT_UNITS, - ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, - FORECAST_MODES, - PLATFORMS, - UPDATE_LISTENER, - MANUFACTURER, - FORECASTS_HOURLY, - FORECASTS_DAILY, ALL_CONDITIONS, PW_PLATFORMS, PW_PLATFORM, @@ -81,7 +53,6 @@ PW_ROUND, ) -from homeassistant.util import Throttle from .weather_update_coordinator import WeatherUpdateCoordinator @@ -115,6 +86,7 @@ "uk2": "uk2_unit", } + @dataclass class PirateWeatherSensorEntityDescription(SensorEntityDescription): """Describes Pirate Weather sensor entity.""" @@ -125,8 +97,8 @@ class PirateWeatherSensorEntityDescription(SensorEntityDescription): uk_unit: str | None = None uk2_unit: str | None = None forecast_mode: list[str] = field(default_factory=list) - - + + # Sensor Types SENSOR_TYPES: dict[str, PirateWeatherSensorEntityDescription] = { "summary": PirateWeatherSensorEntityDescription( @@ -486,12 +458,14 @@ class PirateWeatherSensorEntityDescription(SensorEntityDescription): ), } + class ConditionPicture(NamedTuple): """Entity picture and icon for condition.""" entity_picture: str icon: str + CONDITION_PICTURES: dict[str, ConditionPicture] = { "clear-day": ConditionPicture( entity_picture="/static/images/darksky/weather-sunny.svg", @@ -595,37 +569,34 @@ class ConditionPicture(NamedTuple): ALLOWED_UNITS = ["auto", "si", "us", "ca", "uk", "uk2"] ALERTS_ATTRS = ["time", "description", "expires", "severity", "uri", "regions", "title"] - -HOURS = [i for i in range(168)] -DAYS = [i for i in range(7)] - + +HOURS = list(range(168)) +DAYS = list(range(7)) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNITS): vol.In(ALLOWED_UNITS), vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(LANGUAGE_CODES), - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period, vol.Inclusive( CONF_LATITUDE, "coordinates", "Latitude and longitude must exist together" ): cv.latitude, vol.Inclusive( CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together" ): cv.longitude, - vol.Optional(PW_PLATFORM): cv.multi_select( - PW_PLATFORMS - ), + vol.Optional(PW_PLATFORM): cv.multi_select(PW_PLATFORMS), vol.Optional(PW_PREVPLATFORM): cv.string, vol.Optional(CONF_FORECAST): cv.multi_select(DAYS), vol.Optional(CONF_HOURLY_FORECAST): cv.multi_select(HOURS), vol.Optional(CONF_MONITORED_CONDITIONS, default=None): cv.multi_select( ALL_CONDITIONS - ), + ), } ) - async def async_setup_platform( hass: HomeAssistant, config_entry: ConfigEntry, @@ -641,90 +612,113 @@ async def async_setup_platform( # Define as a sensor platform config_entry[PW_PLATFORM] = [PW_PLATFORMS[0]] - + # Set as no rounding for compatability - config_entry[PW_ROUND] = "No" - + config_entry[PW_ROUND] = "No" + hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data = config_entry - ) + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config_entry + ) ) async def async_setup_entry( - hass: HomeAssistant, + hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Pirate Weather sensor entities based on a config entry.""" - - + domain_data = hass.data[DOMAIN][config_entry.entry_id] - + name = domain_data[CONF_NAME] - api_key = domain_data[CONF_API_KEY] weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR] conditions = domain_data[CONF_MONITORED_CONDITIONS] - latitude = domain_data[CONF_LATITUDE] - longitude = domain_data[CONF_LONGITUDE] - units = domain_data[CONF_UNITS] forecast_days = domain_data[CONF_FORECAST] forecast_hours = domain_data[CONF_HOURLY_FORECAST] - + # Round Output - outputRound = domain_data[PW_ROUND] - + outputRound = domain_data[PW_ROUND] + sensors: list[PirateWeatherSensor] = [] - - + for condition in conditions: - - unit_index = {"si": 1, "us": 2, "ca": 3, "uk": 4, "uk2": 5}.get( - domain_data[CONF_UNITS], 1 - ) - # Save units for conversion later requestUnits = domain_data[CONF_UNITS] - + sensorDescription = SENSOR_TYPES[condition] - + if condition in DEPRECATED_SENSOR_TYPES: _LOGGER.warning("Monitored condition %s is deprecated", condition) - - if not sensorDescription.forecast_mode or "currently" in sensorDescription.forecast_mode: + + if ( + not sensorDescription.forecast_mode + or "currently" in sensorDescription.forecast_mode + ): unique_id = f"{config_entry.unique_id}-sensor-{condition}" - sensors.append(PirateWeatherSensor(weather_coordinator, condition, name, unique_id, forecast_day=None, forecast_hour=None, description=sensorDescription, requestUnits=requestUnits, outputRound=outputRound)) - - + sensors.append( + PirateWeatherSensor( + weather_coordinator, + condition, + name, + unique_id, + forecast_day=None, + forecast_hour=None, + description=sensorDescription, + requestUnits=requestUnits, + outputRound=outputRound, + ) + ) + if forecast_days is not None and "daily" in sensorDescription.forecast_mode: for forecast_day in forecast_days: - unique_id = f"{config_entry.unique_id}-sensor-{condition}-daily-{forecast_day}" + unique_id = ( + f"{config_entry.unique_id}-sensor-{condition}-daily-{forecast_day}" + ) sensors.append( PirateWeatherSensor( - weather_coordinator, condition, name, unique_id, forecast_day=int(forecast_day), forecast_hour=None, description=sensorDescription, requestUnits=requestUnits, outputRound=outputRound + weather_coordinator, + condition, + name, + unique_id, + forecast_day=int(forecast_day), + forecast_hour=None, + description=sensorDescription, + requestUnits=requestUnits, + outputRound=outputRound, ) ) if forecast_hours is not None and "hourly" in sensorDescription.forecast_mode: for forecast_h in forecast_hours: - unique_id = f"{config_entry.unique_id}-sensor-{condition}-hourly-{forecast_h}" + unique_id = ( + f"{config_entry.unique_id}-sensor-{condition}-hourly-{forecast_h}" + ) sensors.append( PirateWeatherSensor( - weather_coordinator, condition, name, unique_id, forecast_day=None, forecast_hour=int(forecast_h), description=sensorDescription, requestUnits=requestUnits, outputRound=outputRound + weather_coordinator, + condition, + name, + unique_id, + forecast_day=None, + forecast_hour=int(forecast_h), + description=sensorDescription, + requestUnits=requestUnits, + outputRound=outputRound, ) ) - + async_add_entities(sensors) class PirateWeatherSensor(SensorEntity): """Class for an PirateWeather sensor.""" - #_attr_should_poll = False + # _attr_should_poll = False _attr_attribution = ATTRIBUTION entity_description: PirateWeatherSensorEntityDescription - + def __init__( self, weather_coordinator: WeatherUpdateCoordinator, @@ -733,29 +727,29 @@ def __init__( unique_id, forecast_day: int, forecast_hour: int, - description: PirateWeatherSensorEntityDescription, + description: PirateWeatherSensorEntityDescription, requestUnits: str, - outputRound: str + outputRound: str, ) -> None: """Initialize the sensor.""" self.client_name = name - - description=description + + description = description self.entity_description = description - self.description=description - + self.description = description + self._weather_coordinator = weather_coordinator - + self._attr_unique_id = unique_id self._attr_name = name - - #self._attr_device_info = DeviceInfo( + + # self._attr_device_info = DeviceInfo( # entry_type=DeviceEntryType.SERVICE, # identifiers={(DOMAIN, unique_id)}, # manufacturer=MANUFACTURER, # name=DEFAULT_NAME, - #) - + # ) + self.forecast_day = forecast_day self.forecast_hour = forecast_hour self.requestUnits = requestUnits @@ -763,11 +757,9 @@ def __init__( self.type = condition self._icon = None self._alerts = None - - - + self._name = description.name - + @property def name(self): """Return the name of the sensor.""" @@ -776,8 +768,7 @@ def name(self): if self.forecast_hour is not None: return f"{self.client_name} {self._name} {self.forecast_hour}h" return f"{self.client_name} {self._name}" - - + @property def available(self) -> bool: """Return if weather data is available from PirateWeather.""" @@ -801,7 +792,7 @@ def native_unit_of_measurement(self): def unit_system(self): """Return the unit system of this entity.""" return self.requestUnits - + @property def entity_picture(self) -> str | None: """Return the entity picture to use in the frontend, if any.""" @@ -819,16 +810,7 @@ def update_unit_of_measurement(self) -> None: self._attr_native_unit_of_measurement = getattr( self.entity_description, unit_key ) - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - if "summary" in self.type and self._icon in CONDITION_PICTURES: - return CONDITION_PICTURES[self._icon][1] - - return SENSOR_TYPES[self.type][6] - @property def icon(self) -> str | None: """Icon to use in the frontend, if any.""" @@ -844,29 +826,27 @@ def icon(self) -> str | None: def extra_state_attributes(self): """Return the state attributes.""" if self.type == "alerts": - extraATTR = self._alerts - extraATTR[ATTR_ATTRIBUTION] = ATTRIBUTION - - return extraATTR + extraATTR = self._alerts + extraATTR[ATTR_ATTRIBUTION] = ATTRIBUTION + + return extraATTR else: - return {ATTR_ATTRIBUTION: ATTRIBUTION} - + return {ATTR_ATTRIBUTION: ATTRIBUTION} @property def native_value(self) -> StateType: - """Return the state of the device.""" - lookup_type = convert_to_camel(self.type) - + """Return the state of the device.""" + self.update_unit_of_measurement() - - if self.type == "alerts": + + if self.type == "alerts": data = self._weather_coordinator.data.alerts() - + alerts = {} if data is None: self._alerts = alerts return data - + multiple_alerts = len(data) > 1 for i, alert in enumerate(data): for attr in ALERTS_ATTRS: @@ -875,37 +855,36 @@ def native_value(self) -> StateType: else: dkey = attr alertsAttr = getattr(alert, attr) - + # Convert time to string if isinstance(alertsAttr, int): - alertsAttr = template_helper.timestamp_local(alertsAttr) - + alertsAttr = template_helper.timestamp_local(alertsAttr) + alerts[dkey] = alertsAttr - - + self._alerts = alerts - native_val = len(data) - - + native_val = len(data) + elif self.type == "minutely_summary": - native_val = getattr(self._weather_coordinator.data.minutely(),"summary", "") - self._icon = getattr(self._weather_coordinator.data.minutely(),"icon", "") + native_val = getattr( + self._weather_coordinator.data.minutely(), "summary", "" + ) + self._icon = getattr(self._weather_coordinator.data.minutely(), "icon", "") elif self.type == "hourly_summary": - native_val = getattr(self._weather_coordinator.data.hourly(),"summary", "") - self._icon = getattr(self._weather_coordinator.data.hourly(),"icon", "") - + native_val = getattr(self._weather_coordinator.data.hourly(), "summary", "") + self._icon = getattr(self._weather_coordinator.data.hourly(), "icon", "") + elif self.forecast_hour is not None: hourly = self._weather_coordinator.data.hourly() if hasattr(hourly, "data"): native_val = self.get_state(hourly.data[self.forecast_hour].d) else: native_val = 0 - + elif self.type == "daily_summary": - native_val = getattr(self._weather_coordinator.data.daily(),"summary", "") - self._icon = getattr(self._weather_coordinator.data.daily(),"icon", "") - - + native_val = getattr(self._weather_coordinator.data.daily(), "summary", "") + self._icon = getattr(self._weather_coordinator.data.daily(), "icon", "") + elif self.forecast_day is not None: daily = self._weather_coordinator.data.daily() if hasattr(daily, "data"): @@ -915,21 +894,19 @@ def native_value(self) -> StateType: else: currently = self._weather_coordinator.data.currently() native_val = self.get_state(currently.d) - - #self._state = native_val - return native_val + # self._state = native_val + return native_val def get_state(self, data): - """ - Return a new state based on the type. + """Return a new state based on the type. If the sensor type is unknown, the current state is returned. """ lookup_type = convert_to_camel(self.type) state = data.get(lookup_type) - + if state is None: return state @@ -938,74 +915,70 @@ def get_state(self, data): # If output rounding is requested, round to nearest integer if self.outputRound == "Yes": - roundingVal = 0 + roundingVal = 0 else: - roundingVal = 1 + roundingVal = 1 # Some state data needs to be rounded to whole values or converted to # percentages if self.type in ["precip_probability", "cloud_cover", "humidity"]: if roundingVal == 0: - state = int(round(state * 100, roundingVal)) + state = int(round(state * 100, roundingVal)) else: - state = round(state * 100, roundingVal) - - + state = round(state * 100, roundingVal) + # Logic to convert from SI to requsested units for compatability # Temps in F if self.requestUnits in ["us"]: - if self.type in [ - "dew_point", - "temperature", - "apparent_temperature", - "temperature_high", - "temperature_low", - "apparent_temperature_high", - "apparent_temperature_low", - ]: - state = ((state * 9 / 5) + 32) - - # Precipitation Accumilation (mm in SI) to inches + if self.type in [ + "dew_point", + "temperature", + "apparent_temperature", + "temperature_high", + "temperature_low", + "apparent_temperature_high", + "apparent_temperature_low", + ]: + state = (state * 9 / 5) + 32 + + # Precipitation Accumilation (mm in SI) to inches if self.requestUnits in ["us"]: - if self.type in [ - "precip_accumulation", - ]: - state = (state * 0.0393701) - - # Precipitation Intensity (mm/h in SI) to inches + if self.type in [ + "precip_accumulation", + ]: + state = state * 0.0393701 + + # Precipitation Intensity (mm/h in SI) to inches if self.requestUnits in ["us"]: - if self.type in [ - "precip_intensity", - ]: - state = (state * 0.0393701) - - - # Km to Miles + if self.type in [ + "precip_intensity", + ]: + state = state * 0.0393701 + + # Km to Miles if self.requestUnits in ["us", "uk", "uk2"]: - if self.type in [ - "visibility", - "nearest_storm_distance", - ]: - state = (state * 0.621371) - - # Meters/second to Miles/hour + if self.type in [ + "visibility", + "nearest_storm_distance", + ]: + state = state * 0.621371 + + # Meters/second to Miles/hour if self.requestUnits in ["us", "uk", "uk2"]: - if self.type in [ - "wind_speed", - "wind_gust", - ]: - state = (state * 2.23694) - - # Meters/second to Km/ hour + if self.type in [ + "wind_speed", + "wind_gust", + ]: + state = state * 2.23694 + + # Meters/second to Km/ hour if self.requestUnits in ["ca"]: - if self.type in [ - "wind_speed", - "wind_gust", - ]: - state = (state * 3.6) - - - + if self.type in [ + "wind_speed", + "wind_gust", + ]: + state = state * 3.6 + if self.type in [ "dew_point", "temperature", @@ -1025,15 +998,14 @@ def get_state(self, data): "wind_speed", "wind_gust", ]: - if roundingVal == 0: - outState = int(round(state, roundingVal)) + outState = int(round(state, roundingVal)) else: - outState = round(state, roundingVal) - + outState = round(state, roundingVal) + else: - outState = state - + outState = state + return outState async def async_added_to_hass(self) -> None: @@ -1041,22 +1013,17 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( self._weather_coordinator.async_add_listener(self.async_write_ha_state) ) - - - #async def async_update(self) -> None: + + # async def async_update(self) -> None: # """Get the latest data from PW and updates the states.""" - # await self._weather_coordinator.async_request_refresh() - + # await self._weather_coordinator.async_request_refresh() def convert_to_camel(data): - """ - Convert snake case (foo_bar_bat) to camel case (fooBarBat). + """Convert snake case (foo_bar_bat) to camel case (fooBarBat). This is not pythonic, but needed for certain situations. """ components = data.split("_") capital_components = "".join(x.title() for x in components[1:]) return f"{components[0]}{capital_components}" - - diff --git a/custom_components/pirateweather/weather.py b/custom_components/pirateweather/weather.py index 8dfe5bf..3683542 100644 --- a/custom_components/pirateweather/weather.py +++ b/custom_components/pirateweather/weather.py @@ -1,21 +1,17 @@ """Support for the Pirate Weather (PW) service.""" from __future__ import annotations -from datetime import timedelta import logging -from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle from homeassistant.util.dt import utc_from_timestamp from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.typing import ConfigType from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.core import HomeAssistant, callback +from .weather_update_coordinator import WeatherUpdateCoordinator +from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.components.weather import ( @@ -30,18 +26,8 @@ ATTR_CONDITION_SNOWY_RAINY, ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_PRESSURE, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_SPEED, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, + ATTR_CONDITION_EXCEPTIONAL, PLATFORM_SCHEMA, - WeatherEntity, Forecast, WeatherEntityFeature, SingleCoordinatorWeatherEntity, @@ -63,35 +49,18 @@ ) from .const import ( - CONF_LANGUAGE, - CONFIG_FLOW_VERSION, - DEFAULT_FORECAST_MODE, - DEFAULT_LANGUAGE, DEFAULT_NAME, DEFAULT_SCAN_INTERVAL, DOMAIN, FORECAST_MODES, - LANGUAGES, CONF_UNITS, - DEFAULT_UNITS, - ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, - FORECAST_MODES, - PLATFORMS, - UPDATE_LISTENER, - MANUFACTURER, - FORECASTS_HOURLY, - FORECASTS_DAILY, PW_PLATFORMS, PW_PLATFORM, PW_PREVPLATFORM, PW_ROUND, - ATTR_FORECAST_CLOUD_COVERAGE, - ATTR_FORECAST_HUMIDITY, - ATTR_FORECAST_NATIVE_VISIBILITY, ) - _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Powered by Pirate Weather" @@ -106,7 +75,7 @@ vol.Optional(CONF_MODE, default="hourly"): vol.In(FORECAST_MODES), vol.Optional(CONF_UNITS): vol.In(["auto", "si", "us", "ca", "uk", "uk2"]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period, } ) @@ -123,14 +92,13 @@ "partly-cloudy-night": ATTR_CONDITION_PARTLYCLOUDY, "hail": ATTR_CONDITION_HAIL, "thunderstorm": ATTR_CONDITION_LIGHTNING, - "tornado": None, + "tornado": ATTR_CONDITION_EXCEPTIONAL, } CONF_UNITS = "units" DEFAULT_NAME = "Pirate Weather" -from .weather_update_coordinator import WeatherUpdateCoordinator async def async_setup_platform( hass: HomeAssistant, @@ -144,18 +112,17 @@ async def async_setup_platform( "Your existing configuration has been imported into the UI automatically " "and can be safely removed from your configuration.yaml file" ) - - + # Add source to config config_entry[PW_PLATFORM] = [PW_PLATFORMS[1]] # Set as no rounding for compatability - config_entry[PW_ROUND] = "No" - + config_entry[PW_ROUND] = "No" + hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data = config_entry - ) + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config_entry + ) ) @@ -165,31 +132,36 @@ def _map_daily_forecast(forecast) -> Forecast: "condition": MAP_CONDITION.get(forecast.d.get("icon")), "native_temperature": forecast.d.get("temperatureHigh"), "native_templow": forecast.d.get("temperatureLow"), - "native_precipitation": forecast.d.get("precipAccumulation")*10, - "precipitation_probability": round(forecast.d.get("precipProbability")*100, 0), - "humidity": round(forecast.d.get("humidity")*100, 2), - "cloud_coverage": round(forecast.d.get("cloudCover")*100, 0), + "native_precipitation": forecast.d.get("precipAccumulation") * 10, + "precipitation_probability": round( + forecast.d.get("precipProbability") * 100, 0 + ), + "humidity": round(forecast.d.get("humidity") * 100, 2), + "cloud_coverage": round(forecast.d.get("cloudCover") * 100, 0), "native_wind_speed": round(forecast.d.get("windSpeed"), 2), "wind_bearing": round(forecast.d.get("windBearing"), 0), } - -def _map_hourly_forecast(forecast: dict[str, Any]) -> Forecast: + +def _map_hourly_forecast(forecast) -> Forecast: return { - "datetime": utc_from_timestamp(forecast.d.get("time")).isoformat(), - "condition": MAP_CONDITION.get(forecast.d.get("icon")), + "datetime": utc_from_timestamp(forecast.d.get("time")).isoformat(), + "condition": MAP_CONDITION.get(forecast.d.get("icon")), "native_temperature": forecast.d.get("temperature"), "native_apparent_temperature": forecast.d.get("apparentTemperature"), "native_dew_point": forecast.d.get("dewPoint"), "native_pressure": forecast.d.get("pressure"), - "native_wind_speed":round(forecast.d.get("windSpeed"), 2), - "wind_bearing": round(forecast.d.get("windBearing"), 0), - "humidity": round(forecast.d.get("humidity")*100, 2), + "native_wind_speed": round(forecast.d.get("windSpeed"), 2), + "wind_bearing": round(forecast.d.get("windBearing"), 0), + "humidity": round(forecast.d.get("humidity") * 100, 2), "native_precipitation": round(forecast.d.get("precipIntensity"), 2), - "precipitation_probability":round(forecast.d.get("precipProbability")*100, 0), - "cloud_coverage": round(forecast.d.get("cloudCover")*100, 0), - "uv_index": round(forecast.d.get("uvIndex"), 2), - } + "precipitation_probability": round( + forecast.d.get("precipProbability") * 100, 0 + ), + "cloud_coverage": round(forecast.d.get("cloudCover") * 100, 0), + "uv_index": round(forecast.d.get("uvIndex"), 2), + } + async def async_setup_entry( hass: HomeAssistant, @@ -200,36 +172,33 @@ async def async_setup_entry( domain_data = hass.data[DOMAIN][config_entry.entry_id] name = domain_data[CONF_NAME] weather_coordinator = domain_data[ENTRY_WEATHER_COORDINATOR] - api_key = domain_data[CONF_API_KEY] - latitude = domain_data[CONF_LATITUDE] - longitude = domain_data[CONF_LONGITUDE] - #units = domain_data[CONF_UNITS] - units = "si" forecast_mode = domain_data[CONF_MODE] - + unique_id = f"{config_entry.unique_id}" - + # Round Output - outputRound = domain_data[PW_ROUND] - - pw_weather = PirateWeather(name, unique_id, forecast_mode, weather_coordinator, outputRound) + outputRound = domain_data[PW_ROUND] + + pw_weather = PirateWeather( + name, unique_id, forecast_mode, weather_coordinator, outputRound + ) async_add_entities([pw_weather], False) - #_LOGGER.info(pw_weather.__dict__) - + # _LOGGER.info(pw_weather.__dict__) + class PirateWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): """Implementation of an PirateWeather sensor.""" _attr_attribution = ATTRIBUTION _attr_should_poll = False - + _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_pressure_unit = UnitOfPressure.MBAR _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_visibility_unit = UnitOfLength.KILOMETERS _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND - + def __init__( self, name: str, @@ -241,28 +210,28 @@ def __init__( """Initialize the sensor.""" super().__init__(weather_coordinator) self._attr_name = name - #self._attr_device_info = DeviceInfo( + # self._attr_device_info = DeviceInfo( # entry_type=DeviceEntryType.SERVICE, # identifiers={(DOMAIN, unique_id)}, # manufacturer=MANUFACTURER, # name=DEFAULT_NAME, - #) + # ) self._weather_coordinator = weather_coordinator self._name = name self._mode = forecast_mode - self._unique_id = unique_id + self._unique_id = unique_id self._ds_data = self._weather_coordinator.data self._ds_currently = self._weather_coordinator.data.currently() self._ds_hourly = self._weather_coordinator.data.hourly() self._ds_daily = self._weather_coordinator.data.daily() - + self.outputRound = outputRound @property def unique_id(self): """Return a unique_id for this entity.""" return self._unique_id - + @property def supported_features(self) -> WeatherEntityFeature: """Determine supported features based on available data sets reported by WeatherKit.""" @@ -270,8 +239,8 @@ def supported_features(self) -> WeatherEntityFeature: features |= WeatherEntityFeature.FORECAST_DAILY features |= WeatherEntityFeature.FORECAST_HOURLY - return features - + return features + @property def available(self): """Return if weather data is available from PirateWeather.""" @@ -291,33 +260,32 @@ def name(self): def native_temperature(self): """Return the temperature.""" temperature = self._weather_coordinator.data.currently().d.get("temperature") - - if self.outputRound=="Yes": - return round(temperature, 0)+0 + + if self.outputRound == "Yes": + return round(temperature, 0) + 0 else: - return round(temperature, 2) - + return round(temperature, 2) + @property def humidity(self): """Return the humidity.""" humidity = self._weather_coordinator.data.currently().d.get("humidity") * 100.0 - if self.outputRound=="Yes": - return round(humidity, 0)+0 + if self.outputRound == "Yes": + return round(humidity, 0) + 0 else: - return round(humidity, 2) - - + return round(humidity, 2) + @property def native_wind_speed(self): """Return the wind speed.""" windspeed = self._weather_coordinator.data.currently().d.get("windSpeed") - if self.outputRound=="Yes": - return round(windspeed, 0)+0 + if self.outputRound == "Yes": + return round(windspeed, 0) + 0 else: - return round(windspeed, 2) - + return round(windspeed, 2) + @property def wind_bearing(self): """Return the wind bearing.""" @@ -327,43 +295,43 @@ def wind_bearing(self): def ozone(self): """Return the ozone level.""" ozone = self._weather_coordinator.data.currently().d.get("ozone") - - if self.outputRound=="Yes": - return round(ozone, 0)+0 + + if self.outputRound == "Yes": + return round(ozone, 0) + 0 else: - return round(ozone, 2) - + return round(ozone, 2) + @property def native_pressure(self): """Return the pressure.""" pressure = self._weather_coordinator.data.currently().d.get("pressure") - - if self.outputRound=="Yes": - return round(pressure, 0)+0 + + if self.outputRound == "Yes": + return round(pressure, 0) + 0 else: - return round(pressure, 2) + return round(pressure, 2) - @property def native_visibility(self): """Return the visibility.""" visibility = self._weather_coordinator.data.currently().d.get("visibility") - - if self.outputRound=="Yes": - return round(visibility, 0)+0 + + if self.outputRound == "Yes": + return round(visibility, 0) + 0 else: - return round(visibility, 2) + return round(visibility, 2) @property def condition(self): """Return the weather condition.""" - return MAP_CONDITION.get(self._weather_coordinator.data.currently().d.get("icon")) - + return MAP_CONDITION.get( + self._weather_coordinator.data.currently().d.get("icon") + ) @callback def _async_forecast_daily(self) -> list[Forecast] | None: - """Return the daily forecast.""" + """Return the daily forecast.""" daily_forecast = self._weather_coordinator.data.daily().data if not daily_forecast: return None @@ -372,29 +340,28 @@ def _async_forecast_daily(self) -> list[Forecast] | None: @callback def _async_forecast_hourly(self) -> list[Forecast] | None: - """Return the hourly forecast.""" + """Return the hourly forecast.""" hourly_forecast = self._weather_coordinator.data.hourly().data - + if not hourly_forecast: return None return [_map_hourly_forecast(f) for f in hourly_forecast] - - + async def async_update(self) -> None: """Get the latest data from PW and updates the states.""" - await self._weather_coordinator.async_request_refresh() - -# async def update(self): -# """Get the latest data from Dark Sky.""" -# await self._dark_sky.update() -# -# self._ds_data = self._dark_sky.data -# currently = self._dark_sky.currently -# self._ds_currently = currently.d if currently else {} -# self._ds_hourly = self._dark_sky.hourly -# self._ds_daily = self._dark_sky.daily - + await self._weather_coordinator.async_request_refresh() + + # async def update(self): + # """Get the latest data from Dark Sky.""" + # await self._dark_sky.update() + # + # self._ds_data = self._dark_sky.data + # currently = self._dark_sky.currently + # self._ds_currently = currently.d if currently else {} + # self._ds_hourly = self._dark_sky.hourly + # self._ds_daily = self._dark_sky.daily + async def async_added_to_hass(self) -> None: """Connect to dispatcher listening for entity data notifications.""" self.async_on_remove( diff --git a/custom_components/pirateweather/weather_update_coordinator.py b/custom_components/pirateweather/weather_update_coordinator.py index a354fed..e83f32e 100644 --- a/custom_components/pirateweather/weather_update_coordinator.py +++ b/custom_components/pirateweather/weather_update_coordinator.py @@ -1,20 +1,13 @@ """Weather data coordinator for the OpenWeatherMap (OWM) service.""" -from datetime import timedelta import logging import async_timeout -import forecastio from forecastio.models import Forecast import json import aiohttp -import asyncio -from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout -import voluptuous as vol -from homeassistant.helpers import sun, aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt from .const import ( @@ -25,7 +18,7 @@ ATTRIBUTION = "Powered by Pirate Weather" - + class WeatherUpdateCoordinator(DataUpdateCoordinator): """Weather data update coordinator.""" @@ -36,45 +29,53 @@ def __init__(self, api_key, latitude, longitude, pw_scan_Int, hass): self.longitude = longitude self.pw_scan_Int = pw_scan_Int self.requested_units = "si" - + self.data = None self.currently = None self.hourly = None self.daily = None self._connect_error = False - super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=pw_scan_Int - ) - - - + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=pw_scan_Int) + async def _async_update_data(self): """Update the data.""" data = {} async with async_timeout.timeout(60): try: data = await self._get_pw_weather() - _LOGGER.info('Pirate Weather data update for ' + str(self.latitude) + ',' + str(self.longitude)) + _LOGGER.info( + "Pirate Weather data update for " + + str(self.latitude) + + "," + + str(self.longitude) + ) except Exception as err: - raise UpdateFailed(f"Error communicating with API: {err}") + raise UpdateFailed(f"Error communicating with API: {err}") return data - async def _get_pw_weather(self): - """Poll weather data from PW.""" - - - forecastString = "https://api.pirateweather.net/forecast/" + self._api_key + "/" + str(self.latitude) + "," + str(self.longitude) + "?units=" + self.requested_units + "&extend=hourly" - - async with aiohttp.ClientSession(raise_for_status=True) as session: - async with session.get(forecastString) as resp: - + """Poll weather data from PW.""" + + forecastString = ( + "https://api.pirateweather.net/forecast/" + + self._api_key + + "/" + + str(self.latitude) + + "," + + str(self.longitude) + + "?units=" + + self.requested_units + + "&extend=hourly" + ) + + async with aiohttp.ClientSession(raise_for_status=True) as session, session.get( + forecastString + ) as resp: resptext = await resp.text() jsonText = json.loads(resptext) headers = resp.headers status = resp.raise_for_status() - + data = Forecast(jsonText, status, headers) return data - diff --git a/example.png b/example.png deleted file mode 100644 index 074f0ab..0000000 Binary files a/example.png and /dev/null differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e0591db --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +colorlog==6.8.0 +homeassistant==2023.10.0 +pip>=21.0,<23.4 +ruff==0.1.9 \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt deleted file mode 100644 index 7d78f01..0000000 --- a/requirements_dev.txt +++ /dev/null @@ -1 +0,0 @@ -homeassistant diff --git a/requirements_test.txt b/requirements_test.txt deleted file mode 100644 index 71f5999..0000000 --- a/requirements_test.txt +++ /dev/null @@ -1 +0,0 @@ -pytest-homeassistant-custom-component diff --git a/scripts/develop b/scripts/develop new file mode 100644 index 0000000..89eda50 --- /dev/null +++ b/scripts/develop @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +# Set the path to custom_components +## This let's us have the structure we want /custom_components/integration_blueprint +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug diff --git a/scripts/lint b/scripts/lint new file mode 100644 index 0000000..9b5b1df --- /dev/null +++ b/scripts/lint @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff check . --fix diff --git a/scripts/setup b/scripts/setup new file mode 100644 index 0000000..141d19f --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt