commit 4a6f844a87250b6a29176cb58d955ce845841944 Author: Martien Date: Sun Oct 26 14:46:43 2025 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14e0f47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,110 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +*.manifest +*.spec + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4becf0 --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# Strike Bitcoin Sensor for Home Assistant + +A custom Home Assistant integration that fetches Bitcoin price data from the Strike API and creates sensor entities for monitoring cryptocurrency prices. + +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) + +## Features + +- 🔄 Real-time Bitcoin (BTC) price tracking from Strike API +- 💱 Configurable currency pairs (default: BTC/USD) +- ⏱️ Customizable update intervals +- 🔐 Secure API key storage +- 🎨 Home Assistant UI configuration flow +- 📊 Additional state attributes (currency, source, last update) +- 🛡️ Comprehensive error handling and logging +- ✨ Follows Home Assistant development best practices + +## Installation + +### HACS (Recommended) + +1. Open HACS in your Home Assistant instance +2. Click on "Integrations" +3. Click the three dots in the top right corner +4. Select "Custom repositories" +5. Add the repository URL: `https://github.com/yourusername/hass-to-be-good` +6. Select category: "Integration" +7. Click "Add" +8. Search for "Strike Bitcoin" in HACS +9. Click "Download" +10. Restart Home Assistant + +### Manual Installation + +1. Copy the `custom_components/strike` folder to your Home Assistant `custom_components` directory +2. Restart Home Assistant + +## Configuration + +### Prerequisites + +You need a Strike API key to use this integration: + +1. Visit [Strike](https://strike.me/) and create an account +2. Navigate to the API section in your Strike dashboard +3. Generate a new API key +4. Copy the API key for use in Home Assistant + +### Setup via UI + +1. Go to **Settings** → **Devices & Services** +2. Click **+ Add Integration** +3. Search for **Strike Bitcoin** +4. Enter your Strike API key +5. (Optional) Configure currency pair (default: BTCUSD) +6. (Optional) Set update interval in seconds (default: 300 seconds / 5 minutes) +7. Click **Submit** + +## Usage + +Once configured, the integration will create a sensor entity: + +- **Entity ID**: `sensor.strike_btcusd` +- **State**: Current Bitcoin price +- **Unit**: Currency (e.g., USD) +- **Icon**: `mdi:bitcoin` + +### State Attributes + +The sensor provides additional information as attributes: + +- `currency`: Target currency (e.g., USD) +- `source`: Currency pair (e.g., BTC/USD) +- `last_update`: Timestamp of last update + +### Example Automation + +```yaml +automation: + - alias: "Bitcoin Price Alert" + trigger: + - platform: numeric_state + entity_id: sensor.strike_btcusd + above: 50000 + action: + - service: notify.notify + data: + message: "Bitcoin price is above $50,000!" +``` + +### Example Lovelace Card + +```yaml +type: entities +entities: + - entity: sensor.strike_btcusd + name: Bitcoin Price + icon: mdi:bitcoin +``` + +## Configuration Options + +### Options Flow + +You can modify the integration settings after setup: + +1. Go to **Settings** → **Devices & Services** +2. Find **Strike Bitcoin** integration +3. Click **Configure** +4. Adjust the update interval +5. Click **Submit** + +## Troubleshooting + +### Integration Not Loading + +- Check Home Assistant logs for errors +- Verify API key is correct +- Ensure you have internet connectivity +- Restart Home Assistant + +### API Errors + +Common errors and solutions: + +- **401 Unauthorized**: Invalid API key - regenerate in Strike dashboard +- **403 Forbidden**: API key doesn't have required permissions +- **Connection timeout**: Check network connectivity or increase timeout in `const.py` + +### Enable Debug Logging + +Add to your `configuration.yaml`: + +```yaml +logger: + default: info + logs: + custom_components.strike: debug +``` + +## Development + +### Repository Structure + +``` +hass-to-be-good/ +├── custom_components/ +│ └── strike/ +│ ├── __init__.py # Integration setup +│ ├── config_flow.py # Configuration UI +│ ├── const.py # Constants and configuration +│ ├── manifest.json # Integration metadata +│ └── sensor.py # Sensor implementation +├── .gitignore +├── hacs.json # HACS configuration +└── README.md +``` + +### Contributing + +Contributions are welcome! Please: + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Submit a pull request + +### Testing + +Before submitting changes: + +1. Test the integration in a Home Assistant development environment +2. Verify configuration flow works correctly +3. Check logs for errors or warnings +4. Ensure code follows Home Assistant style guidelines + +## Strike API Reference + +This integration uses the Strike API v1: + +- **Base URL**: `https://api.strike.me` +- **Endpoint**: `/v1/rates/ticker` +- **Authentication**: Bearer token (API key) +- **Documentation**: [Strike API Docs](https://docs.strike.me/) + +## License + +This project is provided as-is for educational and personal use. + +## Support + +For issues, questions, or feature requests: + +- Open an issue on [GitHub](https://github.com/yourusername/hass-to-be-good/issues) +- Check existing issues for solutions +- Provide Home Assistant logs when reporting bugs + +## Changelog + +### Version 1.0.0 + +- Initial release +- Bitcoin price sensor with Strike API integration +- UI configuration flow +- Configurable update intervals +- Secure API key storage +- Comprehensive error handling + +## Acknowledgments + +- Built for [Home Assistant](https://www.home-assistant.io/) +- Uses [Strike API](https://strike.me/) +- Follows [Home Assistant integration development guidelines](https://developers.home-assistant.io/) diff --git a/custom_components/strike/__init__.py b/custom_components/strike/__init__.py new file mode 100644 index 0000000..099b8be --- /dev/null +++ b/custom_components/strike/__init__.py @@ -0,0 +1,47 @@ +"""The Strike Bitcoin integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Strike Bitcoin from a config entry.""" + _LOGGER.debug("Setting up Strike Bitcoin integration") + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + "session": async_get_clientsession(hass), + "config": entry.data, + } + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + _LOGGER.debug("Unloading Strike Bitcoin integration") + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload config entry.""" + await async_unload_entry(hass, entry) + await async_setup_entry(hass, entry) diff --git a/custom_components/strike/config_flow.py b/custom_components/strike/config_flow.py new file mode 100644 index 0000000..b2efc22 --- /dev/null +++ b/custom_components/strike/config_flow.py @@ -0,0 +1,164 @@ +"""Config flow for Strike Bitcoin integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import ( + API_BASE_URL, + API_TIMEOUT, + CONF_API_KEY, + CONF_CURRENCY_PAIR, + DEFAULT_CURRENCY_PAIR, + DEFAULT_NAME, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + TICKER_ENDPOINT, +) + +_LOGGER = logging.getLogger(__name__) + + +async def validate_api_key(hass: HomeAssistant, api_key: str, currency_pair: str) -> dict[str, Any]: + """Validate the API key by making a test request.""" + session = async_get_clientsession(hass) + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {api_key}", + } + + try: + async with session.get( + f"{API_BASE_URL}{TICKER_ENDPOINT}", + headers=headers, + params={"sourceCurrency": currency_pair[:3], "targetCurrency": currency_pair[3:]}, + timeout=aiohttp.ClientTimeout(total=API_TIMEOUT), + ) as response: + if response.status == 401: + raise InvalidAuth + if response.status == 403: + raise InvalidAuth + if response.status != 200: + _LOGGER.error("API request failed with status %s", response.status) + raise CannotConnect + + data = await response.json() + _LOGGER.debug("API validation successful: %s", data) + return {"title": f"{DEFAULT_NAME} ({currency_pair})"} + + except aiohttp.ClientError as err: + _LOGGER.error("Error connecting to Strike API: %s", err) + raise CannotConnect from err + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error validating API key: %s", err) + raise CannotConnect from err + + +class StrikeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Strike Bitcoin.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + info = await validate_api_key( + self.hass, + user_input[CONF_API_KEY], + user_input.get(CONF_CURRENCY_PAIR, DEFAULT_CURRENCY_PAIR), + ) + + await self.async_set_unique_id( + f"{user_input.get(CONF_CURRENCY_PAIR, DEFAULT_CURRENCY_PAIR)}" + ) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=info["title"], + data=user_input, + ) + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + data_schema = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Optional( + CONF_CURRENCY_PAIR, default=DEFAULT_CURRENCY_PAIR + ): str, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.positive_int, + } + ) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> StrikeOptionsFlowHandler: + """Get the options flow for this handler.""" + return StrikeOptionsFlowHandler(config_entry) + + +class StrikeOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Strike options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.data.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): cv.positive_int, + } + ), + ) + + +class CannotConnect(Exception): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(Exception): + """Error to indicate there is invalid auth.""" diff --git a/custom_components/strike/const.py b/custom_components/strike/const.py new file mode 100644 index 0000000..6583b88 --- /dev/null +++ b/custom_components/strike/const.py @@ -0,0 +1,29 @@ +"""Constants for the Strike Bitcoin integration.""" +from typing import Final + +DOMAIN: Final = "strike" + +# API Configuration +API_BASE_URL: Final = "https://api.strike.me" +API_TIMEOUT: Final = 10 + +# Strike API Endpoints +TICKER_ENDPOINT: Final = "/v1/rates/ticker" + +# Configuration +CONF_API_KEY: Final = "api_key" +CONF_CURRENCY_PAIR: Final = "currency_pair" + +# Default values +DEFAULT_NAME: Final = "Strike Bitcoin" +DEFAULT_CURRENCY_PAIR: Final = "BTCUSD" +DEFAULT_SCAN_INTERVAL: Final = 300 # 5 minutes + +# Attributes +ATTR_CURRENCY: Final = "currency" +ATTR_LAST_UPDATE: Final = "last_update" +ATTR_SOURCE: Final = "source" + +# Device info +MANUFACTURER: Final = "Strike" +MODEL: Final = "Bitcoin Price Ticker" diff --git a/custom_components/strike/manifest.json b/custom_components/strike/manifest.json new file mode 100644 index 0000000..037bd9f --- /dev/null +++ b/custom_components/strike/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "strike", + "name": "Strike Bitcoin", + "codeowners": ["@martien"], + "config_flow": true, + "documentation": "https://github.com/martien/hass-to-be-good", + "issue_tracker": "https://github.com/martien/hass-to-be-good/issues", + "requirements": ["aiohttp>=3.8.0"], + "version": "1.0.0", + "iot_class": "cloud_polling" +} diff --git a/custom_components/strike/sensor.py b/custom_components/strike/sensor.py new file mode 100644 index 0000000..85301db --- /dev/null +++ b/custom_components/strike/sensor.py @@ -0,0 +1,181 @@ +"""Support for Strike Bitcoin price sensor.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +from typing import Any + +import aiohttp + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import ( + API_BASE_URL, + API_TIMEOUT, + ATTR_CURRENCY, + ATTR_LAST_UPDATE, + ATTR_SOURCE, + CONF_API_KEY, + CONF_CURRENCY_PAIR, + DEFAULT_CURRENCY_PAIR, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + MANUFACTURER, + MODEL, + TICKER_ENDPOINT, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Strike Bitcoin sensor based on a config entry.""" + coordinator = StrikeBitcoinCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + async_add_entities([StrikeBitcoinSensor(coordinator, entry)]) + + +class StrikeBitcoinCoordinator(DataUpdateCoordinator): + """Class to manage fetching Strike Bitcoin data.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + self.api_key = entry.data[CONF_API_KEY] + self.currency_pair = entry.data.get(CONF_CURRENCY_PAIR, DEFAULT_CURRENCY_PAIR) + self.session = hass.data[DOMAIN][entry.entry_id]["session"] + + scan_interval = entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=scan_interval), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from Strike API.""" + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + + # Parse currency pair (e.g., BTCUSD -> BTC and USD) + source_currency = self.currency_pair[:3] + target_currency = self.currency_pair[3:] + + try: + async with self.session.get( + f"{API_BASE_URL}{TICKER_ENDPOINT}", + headers=headers, + params={ + "sourceCurrency": source_currency, + "targetCurrency": target_currency, + }, + timeout=aiohttp.ClientTimeout(total=API_TIMEOUT), + ) as response: + if response.status == 401: + raise UpdateFailed("Invalid API key") + if response.status == 403: + raise UpdateFailed("API key forbidden") + if response.status != 200: + raise UpdateFailed(f"API request failed with status {response.status}") + + data = await response.json() + + # Strike API returns the ticker data + # Expected format: {"amount": "50000.00"} + if "amount" not in data: + raise UpdateFailed("Invalid response format from Strike API") + + return { + "price": float(data["amount"]), + "currency": target_currency, + "source_currency": source_currency, + "last_update": datetime.now().isoformat(), + } + + except aiohttp.ClientError as err: + _LOGGER.error("Error connecting to Strike API: %s", err) + raise UpdateFailed(f"Error connecting to Strike API: {err}") from err + except Exception as err: + _LOGGER.exception("Unexpected error fetching Strike data: %s", err) + raise UpdateFailed(f"Unexpected error: {err}") from err + + +class StrikeBitcoinSensor(CoordinatorEntity, SensorEntity): + """Representation of a Strike Bitcoin Price Sensor.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_device_class = SensorDeviceClass.MONETARY + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_icon = "mdi:bitcoin" + + def __init__( + self, coordinator: StrikeBitcoinCoordinator, entry: ConfigEntry + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{entry.entry_id}_btc_price" + self._entry_id = entry.entry_id + self.currency_pair = entry.data.get(CONF_CURRENCY_PAIR, DEFAULT_CURRENCY_PAIR) + + # Set device info + self._attr_device_info = { + "identifiers": {(DOMAIN, entry.entry_id)}, + "name": f"Strike {self.currency_pair}", + "manufacturer": MANUFACTURER, + "model": MODEL, + "entry_type": "service", + } + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.get("price") + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.get("currency", "USD") + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes.""" + if self.coordinator.data is None: + return {} + + return { + ATTR_CURRENCY: self.coordinator.data.get("currency"), + ATTR_SOURCE: f"{self.coordinator.data.get('source_currency')}/{self.coordinator.data.get('currency')}", + ATTR_LAST_UPDATE: self.coordinator.data.get("last_update"), + } + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.coordinator.last_update_success and self.coordinator.data is not None diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..b818eca --- /dev/null +++ b/hacs.json @@ -0,0 +1,7 @@ +{ + "name": "Strike Bitcoin Sensor", + "content_in_root": false, + "filename": "strike", + "render_readme": true, + "homeassistant": "2023.1.0" +}