diff --git a/.devcontainer.json b/.devcontainer.json deleted file mode 100644 index c70f1f5..0000000 --- a/.devcontainer.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "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 04afab9..0c8b08c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,9 +10,6 @@ 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 940b673..2f750eb 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.1.1" + - uses: "actions/checkout@v4" - uses: home-assistant/actions/hassfest@master \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 1ca9818..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,33 +0,0 @@ -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 . --fix --exit-zero - - - name: "Auto Commit" - uses: stefanzweifel/git-auto-commit-action@v5.0.0 - with: - commit_message: 'style fixes by ruff' diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 63110b0..db6b2ac 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.1.1" + - uses: "actions/checkout@v4" - name: HACS validation uses: "hacs/action@main" with: diff --git a/.ruff.toml b/.ruff.toml deleted file mode 100644 index efc104e..0000000 --- a/.ruff.toml +++ /dev/null @@ -1,67 +0,0 @@ -# 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 deleted file mode 100644 index 3aa1c50..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "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 deleted file mode 100644 index 153d903..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,132 +0,0 @@ -# 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 deleted file mode 100644 index 21666e7..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,61 +0,0 @@ -# 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 [black](https://github.com/ambv/black) 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 deleted file mode 100644 index 539bb19..0000000 --- a/config/configuration.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# 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 74d93bc..eb4d841 100644 --- a/custom_components/pirateweather/__init__.py +++ b/custom_components/pirateweather/__init__.py @@ -4,10 +4,14 @@ 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 ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -23,23 +27,31 @@ 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_PLATFORM, + PW_PREVPLATFORM, 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" @@ -59,45 +71,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) + #_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 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}" - + # 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, @@ -115,17 +127,16 @@ 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 @@ -135,36 +146,33 @@ 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 251deec..d949667 100644 --- a/custom_components/pirateweather/config_flow.py +++ b/custom_components/pirateweather/config_flow.py @@ -3,6 +3,8 @@ import logging from datetime import timedelta +import forecastio +from forecastio.models import Forecast import json import aiohttp @@ -31,6 +33,8 @@ LANGUAGES, CONF_UNITS, DEFAULT_UNITS, + FORECASTS_DAILY, + FORECASTS_HOURLY, ALL_CONDITIONS, PW_PLATFORMS, PW_PLATFORM, @@ -61,37 +65,40 @@ 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] @@ -100,57 +107,53 @@ async def async_step_user(self, user_input=None): 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() - - # 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}" - ) + 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}") + 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" - + 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 - + _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) + 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: @@ -160,25 +163,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) @@ -193,127 +196,109 @@ 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( + { + 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( PW_PLATFORM, - default=self.config_entry.options.get( - PW_PLATFORM, - self.config_entry.data.get(PW_PLATFORM, []), - ), - ): cv.multi_select(PW_PLATFORMS), - vol.Optional( + self.config_entry.data.get(PW_PLATFORM, []), + ), + ): cv.multi_select(PW_PLATFORMS), + vol.Optional( + CONF_MODE, + default=self.config_entry.options.get( CONF_MODE, - default=self.config_entry.options.get( - CONF_MODE, - self.config_entry.data.get( - CONF_MODE, DEFAULT_FORECAST_MODE - ), - ), - ): vol.In(FORECAST_MODES), - vol.Optional( + self.config_entry.data.get(CONF_MODE, DEFAULT_FORECAST_MODE), + ), + ): vol.In(FORECAST_MODES), + vol.Optional( + CONF_LANGUAGE, + default=self.config_entry.options.get( CONF_LANGUAGE, - 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"]), - } + 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: + 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 + resptext = await resp.text() + jsonText = json.loads(resptext) + headers = resp.headers + status = resp.status + + return status diff --git a/custom_components/pirateweather/const.py b/custom_components/pirateweather/const.py index e887a07..2343240 100644 --- a/custom_components/pirateweather/const.py +++ b/custom_components/pirateweather/const.py @@ -1,5 +1,6 @@ """Consts for the OpenWeatherMap.""" from __future__ import annotations +from datetime import timedelta from homeassistant.components.sensor import ( SensorDeviceClass, @@ -75,7 +76,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" @@ -86,50 +87,49 @@ 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 +325,4 @@ name="Cloud coverage", native_unit_of_measurement=PERCENTAGE, ), -) +) \ No newline at end of file diff --git a/custom_components/pirateweather/sensor.py b/custom_components/pirateweather/sensor.py index e427e8a..3b12ec0 100644 --- a/custom_components/pirateweather/sensor.py +++ b/custom_components/pirateweather/sensor.py @@ -1,27 +1,39 @@ """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, @@ -44,12 +56,24 @@ 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, @@ -57,6 +81,7 @@ PW_ROUND, ) +from homeassistant.util import Throttle from .weather_update_coordinator import WeatherUpdateCoordinator @@ -90,7 +115,6 @@ "uk2": "uk2_unit", } - @dataclass class PirateWeatherSensorEntityDescription(SensorEntityDescription): """Describes Pirate Weather sensor entity.""" @@ -101,7 +125,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( @@ -461,14 +486,12 @@ 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", @@ -572,34 +595,37 @@ 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)] - +DAYS = [i for i in 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, @@ -615,121 +641,90 @@ 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] + 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] + 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, @@ -738,29 +733,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 @@ -768,9 +763,11 @@ 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.""" @@ -779,7 +776,8 @@ 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.""" @@ -803,7 +801,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.""" @@ -821,6 +819,7 @@ def update_unit_of_measurement(self) -> None: self._attr_native_unit_of_measurement = getattr( self.entity_description, unit_key ) + @property def icon(self): @@ -829,7 +828,7 @@ def icon(self): 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.""" @@ -845,28 +844,29 @@ 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.""" + """Return the state of the device.""" lookup_type = convert_to_camel(self.type) - + 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,36 +875,37 @@ 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"): @@ -914,19 +915,21 @@ def native_value(self) -> StateType: else: currently = self._weather_coordinator.data.currently() native_val = self.get_state(currently.d) - - # self._state = 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 @@ -935,70 +938,74 @@ 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", @@ -1018,14 +1025,15 @@ 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: @@ -1033,17 +1041,22 @@ 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 d12cee4..8dfe5bf 100644 --- a/custom_components/pirateweather/weather.py +++ b/custom_components/pirateweather/weather.py @@ -1,16 +1,21 @@ """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.components.weather import ( @@ -25,7 +30,18 @@ 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, PLATFORM_SCHEMA, + WeatherEntity, Forecast, WeatherEntityFeature, SingleCoordinatorWeatherEntity, @@ -47,18 +63,35 @@ ) 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" @@ -73,7 +106,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, } ) @@ -97,6 +130,7 @@ DEFAULT_NAME = "Pirate Weather" +from .weather_update_coordinator import WeatherUpdateCoordinator async def async_setup_platform( hass: HomeAssistant, @@ -110,17 +144,18 @@ 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 + ) ) @@ -130,36 +165,31 @@ 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: 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, @@ -173,35 +203,33 @@ async def async_setup_entry( api_key = domain_data[CONF_API_KEY] latitude = domain_data[CONF_LATITUDE] longitude = domain_data[CONF_LONGITUDE] - # units = domain_data[CONF_UNITS] + #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, @@ -213,28 +241,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.""" @@ -242,8 +270,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.""" @@ -263,32 +291,33 @@ 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.""" @@ -298,43 +327,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 @@ -343,28 +372,29 @@ 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 3764bbb..a354fed 100644 --- a/custom_components/pirateweather/weather_update_coordinator.py +++ b/custom_components/pirateweather/weather_update_coordinator.py @@ -1,13 +1,20 @@ """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 ( @@ -18,7 +25,7 @@ ATTRIBUTION = "Powered by Pirate Weather" - + class WeatherUpdateCoordinator(DataUpdateCoordinator): """Weather data update coordinator.""" @@ -29,52 +36,45 @@ 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 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: - resptext = await resp.text() - jsonText = json.loads(resptext) - headers = resp.headers - status = resp.raise_for_status() - - data = Forecast(jsonText, status, headers) + async with 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 new file mode 100644 index 0000000..074f0ab Binary files /dev/null and b/example.png differ diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e0591db..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -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 new file mode 100644 index 0000000..7d78f01 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1 @@ +homeassistant diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..71f5999 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1 @@ +pytest-homeassistant-custom-component diff --git a/scripts/develop b/scripts/develop deleted file mode 100644 index 89eda50..0000000 --- a/scripts/develop +++ /dev/null @@ -1,20 +0,0 @@ -#!/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 deleted file mode 100644 index 9b5b1df..0000000 --- a/scripts/lint +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -ruff check . --fix diff --git a/scripts/setup b/scripts/setup deleted file mode 100644 index 141d19f..0000000 --- a/scripts/setup +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -python3 -m pip install --requirement requirements.txt