1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-19 18:38:58 +00:00

Add Firefly III integration (#147062)

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Erwin Douna
2025-09-30 23:34:33 +02:00
committed by GitHub
parent 1b495ecafa
commit 36cc3682ca
29 changed files with 1453 additions and 0 deletions

View File

@@ -203,6 +203,7 @@ homeassistant.components.feedreader.*
homeassistant.components.file_upload.*
homeassistant.components.filesize.*
homeassistant.components.filter.*
homeassistant.components.firefly_iii.*
homeassistant.components.fitbit.*
homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.*

2
CODEOWNERS generated
View File

@@ -492,6 +492,8 @@ build.json @home-assistant/supervisor
/tests/components/filesize/ @gjohansson-ST
/homeassistant/components/filter/ @dgomes
/tests/components/filter/ @dgomes
/homeassistant/components/firefly_iii/ @erwindouna
/tests/components/firefly_iii/ @erwindouna
/homeassistant/components/fireservicerota/ @cyberjunky
/tests/components/fireservicerota/ @cyberjunky
/homeassistant/components/firmata/ @DaAwesomeP

View File

@@ -0,0 +1,27 @@
"""The Firefly III integration."""
from __future__ import annotations
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
"""Set up Firefly III from a config entry."""
coordinator = FireflyDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: FireflyConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@@ -0,0 +1,97 @@
"""Config flow for the Firefly III integration."""
from __future__ import annotations
import logging
from typing import Any
from pyfirefly import (
Firefly,
FireflyAuthenticationError,
FireflyConnectionError,
FireflyTimeoutError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL): str,
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
vol.Required(CONF_API_KEY): str,
}
)
async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
"""Validate the user input allows us to connect."""
try:
client = Firefly(
api_url=data[CONF_URL],
api_key=data[CONF_API_KEY],
session=async_get_clientsession(hass),
)
await client.get_about()
except FireflyAuthenticationError:
raise InvalidAuth from None
except FireflyConnectionError as err:
raise CannotConnect from err
except FireflyTimeoutError as err:
raise FireflyClientTimeout from err
return True
class FireflyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Firefly III."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
try:
await _validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except FireflyClientTimeout:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=user_input[CONF_URL], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
class FireflyClientTimeout(HomeAssistantError):
"""Error to indicate a timeout occurred."""

View File

@@ -0,0 +1,6 @@
"""Constants for the Firefly III integration."""
DOMAIN = "firefly_iii"
MANUFACTURER = "Firefly III"
NAME = "Firefly III"

View File

@@ -0,0 +1,137 @@
"""Data Update Coordinator for Firefly III integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from aiohttp import CookieJar
from pyfirefly import (
Firefly,
FireflyAuthenticationError,
FireflyConnectionError,
FireflyTimeoutError,
)
from pyfirefly.models import Account, Bill, Budget, Category, Currency
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type FireflyConfigEntry = ConfigEntry[FireflyDataUpdateCoordinator]
DEFAULT_SCAN_INTERVAL = timedelta(minutes=5)
@dataclass
class FireflyCoordinatorData:
"""Data structure for Firefly III coordinator data."""
accounts: list[Account]
categories: list[Category]
category_details: list[Category]
budgets: list[Budget]
bills: list[Bill]
primary_currency: Currency
class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]):
"""Coordinator to manage data updates for Firefly III integration."""
config_entry: FireflyConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: FireflyConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=DEFAULT_SCAN_INTERVAL,
)
self.firefly = Firefly(
api_url=self.config_entry.data[CONF_URL],
api_key=self.config_entry.data[CONF_API_KEY],
session=async_create_clientsession(
self.hass,
self.config_entry.data[CONF_VERIFY_SSL],
cookie_jar=CookieJar(unsafe=True),
),
)
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.firefly.get_about()
except FireflyAuthenticationError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except FireflyConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except FireflyTimeoutError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
async def _async_update_data(self) -> FireflyCoordinatorData:
"""Fetch data from Firefly III API."""
now = datetime.now()
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end_date = now
try:
accounts = await self.firefly.get_accounts()
categories = await self.firefly.get_categories()
category_details = [
await self.firefly.get_category(
category_id=int(category.id), start=start_date, end=end_date
)
for category in categories
]
primary_currency = await self.firefly.get_currency_primary()
budgets = await self.firefly.get_budgets()
bills = await self.firefly.get_bills()
except FireflyAuthenticationError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except FireflyConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except FireflyTimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
return FireflyCoordinatorData(
accounts=accounts,
categories=categories,
category_details=category_details,
budgets=budgets,
bills=bills,
primary_currency=primary_currency,
)

View File

@@ -0,0 +1,40 @@
"""Base entity for Firefly III integration."""
from __future__ import annotations
from yarl import URL
from homeassistant.const import CONF_URL
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import FireflyDataUpdateCoordinator
class FireflyBaseEntity(CoordinatorEntity[FireflyDataUpdateCoordinator]):
"""Base class for Firefly III entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize a Firefly entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer=MANUFACTURER,
configuration_url=URL(coordinator.config_entry.data[CONF_URL]),
identifiers={
(
DOMAIN,
f"{coordinator.config_entry.entry_id}_{self.entity_description.key}",
)
},
)

View File

@@ -0,0 +1,18 @@
{
"entity": {
"sensor": {
"account_type": {
"default": "mdi:bank",
"state": {
"expense": "mdi:cash-minus",
"revenue": "mdi:cash-plus",
"asset": "mdi:account-cash",
"liability": "mdi:hand-coin"
}
},
"category": {
"default": "mdi:label"
}
}
}
}

View File

@@ -0,0 +1,10 @@
{
"domain": "firefly_iii",
"name": "Firefly III",
"codeowners": ["@erwindouna"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/firefly_iii",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.5"]
}

View File

@@ -0,0 +1,68 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
No custom actions are defined.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates:
status: exempt
comment: |
No explicit parallel updates are defined.
reauthentication-flow:
status: todo
comment: |
No reauthentication flow is defined. It will be done in a next iteration.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -0,0 +1,142 @@
"""Sensor platform for Firefly III integration."""
from __future__ import annotations
from pyfirefly.models import Account, Category
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.components.sensor.const import SensorDeviceClass
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FireflyConfigEntry, FireflyDataUpdateCoordinator
from .entity import FireflyBaseEntity
ACCOUNT_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="account_type",
translation_key="account",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.TOTAL,
),
)
CATEGORY_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="category",
translation_key="category",
device_class=SensorDeviceClass.MONETARY,
state_class=SensorStateClass.TOTAL,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FireflyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Firefly III sensor platform."""
coordinator = entry.runtime_data
entities: list[SensorEntity] = [
FireflyAccountEntity(
coordinator=coordinator,
entity_description=description,
account=account,
)
for account in coordinator.data.accounts
for description in ACCOUNT_SENSORS
]
entities.extend(
FireflyCategoryEntity(
coordinator=coordinator,
entity_description=description,
category=category,
)
for category in coordinator.data.category_details
for description in CATEGORY_SENSORS
)
async_add_entities(entities)
class FireflyAccountEntity(FireflyBaseEntity, SensorEntity):
"""Entity for Firefly III account."""
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
entity_description: SensorEntityDescription,
account: Account,
) -> None:
"""Initialize Firefly account entity."""
super().__init__(coordinator, entity_description)
self._account = account
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{account.id}"
self._attr_name = account.attributes.name
self._attr_native_unit_of_measurement = (
coordinator.data.primary_currency.attributes.code
)
# Account type state doesn't go well with the icons.json. Need to fix it.
if account.attributes.type == "expense":
self._attr_icon = "mdi:cash-minus"
elif account.attributes.type == "asset":
self._attr_icon = "mdi:account-cash"
elif account.attributes.type == "revenue":
self._attr_icon = "mdi:cash-plus"
elif account.attributes.type == "liability":
self._attr_icon = "mdi:hand-coin"
else:
self._attr_icon = "mdi:bank"
@property
def native_value(self) -> str | None:
"""Return the state of the sensor."""
return self._account.attributes.current_balance
@property
def extra_state_attributes(self) -> dict[str, str] | None:
"""Return extra state attributes for the account entity."""
return {
"account_role": self._account.attributes.account_role or "",
"account_type": self._account.attributes.type or "",
"current_balance": str(self._account.attributes.current_balance or ""),
}
class FireflyCategoryEntity(FireflyBaseEntity, SensorEntity):
"""Entity for Firefly III category."""
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
entity_description: SensorEntityDescription,
category: Category,
) -> None:
"""Initialize Firefly category entity."""
super().__init__(coordinator, entity_description)
self._category = category
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{entity_description.key}_{category.id}"
self._attr_name = category.attributes.name
self._attr_native_unit_of_measurement = (
coordinator.data.primary_currency.attributes.code
)
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
spent_items = self._category.attributes.spent or []
earned_items = self._category.attributes.earned or []
spent = sum(float(item.sum) for item in spent_items if item.sum is not None)
earned = sum(float(item.sum) for item in earned_items if item.sum is not None)
if spent == 0 and earned == 0:
return None
return spent + earned

View File

@@ -0,0 +1,39 @@
{
"config": {
"step": {
"user": {
"data": {
"url": "[%key:common::config_flow::data::url%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"url": "[%key:common::config_flow::data::url%]",
"api_key": "The API key for authenticating with Firefly",
"verify_ssl": "Verify the SSL certificate of the Firefly instance"
},
"description": "You can create an API key in the Firefly UI. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"exceptions": {
"cannot_connect": {
"message": "An error occurred while trying to connect to the Firefly instance: {error}"
},
"invalid_auth": {
"message": "An error occurred while trying to authenticate: {error}"
},
"timeout_connect": {
"message": "A timeout occurred while trying to connect to the Firefly instance: {error}"
}
}
}

View File

@@ -201,6 +201,7 @@ FLOWS = {
"fibaro",
"file",
"filesize",
"firefly_iii",
"fireservicerota",
"fitbit",
"fivem",

View File

@@ -1984,6 +1984,12 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
"firefly_iii": {
"name": "Firefly III",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
},
"fireservicerota": {
"name": "FireServiceRota",
"integration_type": "hub",

10
mypy.ini generated
View File

@@ -1786,6 +1786,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.firefly_iii.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.fitbit.*]
check_untyped_defs = true
disallow_incomplete_defs = true

3
requirements_all.txt generated
View File

@@ -2020,6 +2020,9 @@ pyfibaro==0.8.3
# homeassistant.components.fido
pyfido==2.1.2
# homeassistant.components.firefly_iii
pyfirefly==0.1.5
# homeassistant.components.fireservicerota
pyfireservicerota==0.0.46

View File

@@ -1689,6 +1689,9 @@ pyfibaro==0.8.3
# homeassistant.components.fido
pyfido==2.1.2
# homeassistant.components.firefly_iii
pyfirefly==0.1.5
# homeassistant.components.fireservicerota
pyfireservicerota==0.0.46

View File

@@ -0,0 +1,13 @@
"""Tests for the Firefly III integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -0,0 +1,95 @@
"""Common fixtures for the Firefly III tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from pyfirefly.models import About, Account, Bill, Budget, Category, Currency
import pytest
from homeassistant.components.firefly_iii.const import DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from tests.common import (
MockConfigEntry,
load_json_array_fixture,
load_json_value_fixture,
)
MOCK_TEST_CONFIG = {
CONF_URL: "https://127.0.0.1:8080/",
CONF_API_KEY: "test_api_key",
CONF_VERIFY_SSL: True,
}
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.firefly_iii.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_firefly_client() -> Generator[AsyncMock]:
"""Mock Firefly client with dynamic exception injection support."""
with (
patch(
"homeassistant.components.firefly_iii.config_flow.Firefly"
) as mock_client,
patch(
"homeassistant.components.firefly_iii.coordinator.Firefly", new=mock_client
),
):
client = mock_client.return_value
client.get_about = AsyncMock(
return_value=About.from_dict(load_json_value_fixture("about.json", DOMAIN))
)
client.get_accounts = AsyncMock(
return_value=[
Account.from_dict(account)
for account in load_json_array_fixture("accounts.json", DOMAIN)
]
)
client.get_categories = AsyncMock(
return_value=[
Category.from_dict(category)
for category in load_json_array_fixture("categories.json", DOMAIN)
]
)
client.get_category = AsyncMock(
return_value=Category.from_dict(
load_json_value_fixture("category.json", DOMAIN)
)
)
client.get_currency_primary = AsyncMock(
return_value=Currency.from_dict(
load_json_value_fixture("primary_currency.json", DOMAIN)
)
)
client.get_budgets = AsyncMock(
return_value=[
Budget.from_dict(budget)
for budget in load_json_array_fixture("budgets.json", DOMAIN)
]
)
client.get_bills = AsyncMock(
return_value=[
Bill.from_dict(bill)
for bill in load_json_array_fixture("bills.json", DOMAIN)
]
)
yield client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="Firefly III test",
data=MOCK_TEST_CONFIG,
entry_id="firefly_iii_test_entry_123",
)

View File

@@ -0,0 +1,7 @@
{
"version": "5.8.0-alpha.1",
"api_version": "5.8.0-alpha.1",
"php_version": "8.1.5",
"os": "Linux",
"driver": "mysql"
}

View File

@@ -0,0 +1,178 @@
[
{
"type": "accounts",
"id": "2",
"attributes": {
"created_at": "2018-09-17T12:46:47+01:00",
"updated_at": "2018-09-17T12:46:47+01:00",
"active": false,
"order": 1,
"name": "My checking account",
"type": "asset",
"account_role": "defaultAsset",
"currency_id": "12",
"currency_code": "EUR",
"currency_symbol": "$",
"currency_decimal_places": 2,
"native_currency_id": "12",
"native_currency_code": "EUR",
"native_currency_symbol": "$",
"native_currency_decimal_places": 2,
"current_balance": "123.45",
"native_current_balance": "123.45",
"current_balance_date": "2018-09-17T12:46:47+01:00",
"notes": "Some example notes",
"monthly_payment_date": "2018-09-17T12:46:47+01:00",
"credit_card_type": "monthlyFull",
"account_number": "7009312345678",
"iban": "GB98MIDL07009312345678",
"bic": "BOFAUS3N",
"virtual_balance": "123.45",
"native_virtual_balance": "123.45",
"opening_balance": "-1012.12",
"native_opening_balance": "-1012.12",
"opening_balance_date": "2018-09-17T12:46:47+01:00",
"liability_type": "loan",
"liability_direction": "credit",
"interest": "5.3",
"interest_period": "monthly",
"current_debt": "1012.12",
"include_net_worth": true,
"longitude": 5.916667,
"latitude": 51.983333,
"zoom_level": 6
}
},
{
"type": "accounts",
"id": "3",
"attributes": {
"created_at": "2019-01-01T10:00:00+01:00",
"updated_at": "2020-01-01T10:00:00+01:00",
"active": true,
"order": 2,
"name": "Savings Account",
"type": "expense",
"account_role": "savingsAsset",
"currency_id": "13",
"currency_code": "USD",
"currency_symbol": "$",
"currency_decimal_places": 2,
"native_currency_id": "13",
"native_currency_code": "USD",
"native_currency_symbol": "$",
"native_currency_decimal_places": 2,
"current_balance": "5000.00",
"native_current_balance": "5000.00",
"current_balance_date": "2020-01-01T10:00:00+01:00",
"notes": "Main savings account",
"monthly_payment_date": null,
"credit_card_type": null,
"account_number": "1234567890",
"iban": "US12345678901234567890",
"bic": "CITIUS33",
"virtual_balance": "0.00",
"native_virtual_balance": "0.00",
"opening_balance": "1000.00",
"native_opening_balance": "1000.00",
"opening_balance_date": "2019-01-01T10:00:00+01:00",
"liability_type": null,
"liability_direction": null,
"interest": "1.2",
"interest_period": "yearly",
"current_debt": null,
"include_net_worth": true,
"longitude": -74.006,
"latitude": 40.7128,
"zoom_level": 8
}
},
{
"type": "accounts",
"id": "4",
"attributes": {
"created_at": "2021-05-10T09:30:00+01:00",
"updated_at": "2022-05-10T09:30:00+01:00",
"active": true,
"order": 3,
"name": "Credit Card",
"type": "liability",
"account_role": "creditCard",
"currency_id": "14",
"currency_code": "GBP",
"currency_symbol": "£",
"currency_decimal_places": 2,
"native_currency_id": "14",
"native_currency_code": "GBP",
"native_currency_symbol": "£",
"native_currency_decimal_places": 2,
"current_balance": "-250.00",
"native_current_balance": "-250.00",
"current_balance_date": "2022-05-10T09:30:00+01:00",
"notes": "Credit card account",
"monthly_payment_date": "2022-05-15T09:30:00+01:00",
"credit_card_type": "monthlyFull",
"account_number": "9876543210",
"iban": "GB29NWBK60161331926819",
"bic": "NWBKGB2L",
"virtual_balance": "0.00",
"native_virtual_balance": "0.00",
"opening_balance": "0.00",
"native_opening_balance": "0.00",
"opening_balance_date": "2021-05-10T09:30:00+01:00",
"liability_type": "credit",
"liability_direction": "debit",
"interest": "19.99",
"interest_period": "monthly",
"current_debt": "250.00",
"include_net_worth": false,
"longitude": 0.1278,
"latitude": 51.5074,
"zoom_level": 10
}
},
{
"type": "accounts",
"id": "4",
"attributes": {
"created_at": "2021-05-10T09:30:00+01:00",
"updated_at": "2022-05-10T09:30:00+01:00",
"active": true,
"order": 3,
"name": "Credit Card",
"type": "revenue",
"account_role": "creditCard",
"currency_id": "14",
"currency_code": "GBP",
"currency_symbol": "£",
"currency_decimal_places": 2,
"native_currency_id": "14",
"native_currency_code": "GBP",
"native_currency_symbol": "£",
"native_currency_decimal_places": 2,
"current_balance": "-250.00",
"native_current_balance": "-250.00",
"current_balance_date": "2022-05-10T09:30:00+01:00",
"notes": "Credit card account",
"monthly_payment_date": "2022-05-15T09:30:00+01:00",
"credit_card_type": "monthlyFull",
"account_number": "9876543210",
"iban": "GB29NWBK60161331926819",
"bic": "NWBKGB2L",
"virtual_balance": "0.00",
"native_virtual_balance": "0.00",
"opening_balance": "0.00",
"native_opening_balance": "0.00",
"opening_balance_date": "2021-05-10T09:30:00+01:00",
"liability_type": "credit",
"liability_direction": "debit",
"interest": "19.99",
"interest_period": "monthly",
"current_debt": "250.00",
"include_net_worth": false,
"longitude": 0.1278,
"latitude": 51.5074,
"zoom_level": 10
}
}
]

View File

@@ -0,0 +1,44 @@
[
{
"type": "bills",
"id": "2",
"attributes": {
"created_at": "2018-09-17T12:46:47+01:00",
"updated_at": "2018-09-17T12:46:47+01:00",
"currency_id": "5",
"currency_code": "EUR",
"currency_symbol": "$",
"currency_decimal_places": 2,
"native_currency_id": "5",
"native_currency_code": "EUR",
"native_currency_symbol": "$",
"native_currency_decimal_places": 2,
"name": "Rent",
"amount_min": "123.45",
"amount_max": "123.45",
"native_amount_min": "123.45",
"native_amount_max": "123.45",
"date": "2018-09-17T12:46:47+01:00",
"end_date": "2018-09-17T12:46:47+01:00",
"extension_date": "2018-09-17T12:46:47+01:00",
"repeat_freq": "monthly",
"skip": 0,
"active": true,
"order": 1,
"notes": "Some example notes",
"next_expected_match": "2018-09-17T12:46:47+01:00",
"next_expected_match_diff": "today",
"object_group_id": "5",
"object_group_order": 5,
"object_group_title": "Example Group",
"pay_dates": ["2018-09-17T12:46:47+01:00"],
"paid_dates": [
{
"transaction_group_id": "123",
"transaction_journal_id": "123",
"date": "2018-09-17T12:46:47+01:00"
}
]
}
}
]

View File

@@ -0,0 +1,35 @@
[
{
"type": "budgets",
"id": "2",
"attributes": {
"created_at": "2018-09-17T12:46:47+01:00",
"updated_at": "2018-09-17T12:46:47+01:00",
"name": "Bills",
"active": false,
"notes": "Some notes",
"order": 5,
"auto_budget_type": "reset",
"currency_id": "12",
"currency_code": "EUR",
"currency_symbol": "$",
"currency_decimal_places": 2,
"native_currency_id": "5",
"native_currency_code": "EUR",
"native_currency_symbol": "$",
"native_currency_decimal_places": 2,
"auto_budget_amount": "-1012.12",
"native_auto_budget_amount": "-1012.12",
"auto_budget_period": "monthly",
"spent": [
{
"sum": "123.45",
"currency_id": "5",
"currency_code": "USD",
"currency_symbol": "$",
"currency_decimal_places": 2
}
]
}
}
]

View File

@@ -0,0 +1,34 @@
[
{
"type": "categories",
"id": "2",
"attributes": {
"created_at": "2018-09-17T12:46:47+01:00",
"updated_at": "2018-09-17T12:46:47+01:00",
"name": "Lunch",
"notes": "Some example notes",
"native_currency_id": "5",
"native_currency_code": "EUR",
"native_currency_symbol": "$",
"native_currency_decimal_places": 2,
"spent": [
{
"currency_id": "5",
"currency_code": "USD",
"currency_symbol": "$",
"currency_decimal_places": 2,
"sum": "-12423.45"
}
],
"earned": [
{
"currency_id": "5",
"currency_code": "USD",
"currency_symbol": "$",
"currency_decimal_places": 2,
"sum": "123.45"
}
]
}
}
]

View File

@@ -0,0 +1,32 @@
{
"type": "categories",
"id": "2",
"attributes": {
"created_at": "2018-09-17T12:46:47+01:00",
"updated_at": "2018-09-17T12:46:47+01:00",
"name": "Lunch",
"notes": "Some example notes",
"native_currency_id": "5",
"native_currency_code": "EUR",
"native_currency_symbol": "$",
"native_currency_decimal_places": 2,
"spent": [
{
"currency_id": "5",
"currency_code": "USD",
"currency_symbol": "$",
"currency_decimal_places": 2,
"sum": "-12423.45"
}
],
"earned": [
{
"currency_id": "5",
"currency_code": "USD",
"currency_symbol": "$",
"currency_decimal_places": 2,
"sum": "123.45"
}
]
}
}

View File

@@ -0,0 +1,15 @@
{
"type": "currencies",
"id": "2",
"attributes": {
"created_at": "2018-09-17T12:46:47+01:00",
"updated_at": "2018-09-17T12:46:47+01:00",
"enabled": true,
"default": false,
"native": false,
"code": "AMS",
"name": "Ankh-Morpork dollar",
"symbol": "AM$",
"decimal_places": 2
}
}

View File

@@ -0,0 +1,225 @@
# serializer version: 1
# name: test_all_entities[sensor.firefly_iii_test_credit_card-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.firefly_iii_test_credit_card',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': 'mdi:hand-coin',
'original_name': 'Credit Card',
'platform': 'firefly_iii',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'account',
'unique_id': 'None_account_type_4',
'unit_of_measurement': 'AMS',
})
# ---
# name: test_all_entities[sensor.firefly_iii_test_credit_card-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'account_role': 'creditCard',
'account_type': 'liability',
'current_balance': '-250.00',
'device_class': 'monetary',
'friendly_name': 'Firefly III test Credit Card',
'icon': 'mdi:hand-coin',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'AMS',
}),
'context': <ANY>,
'entity_id': 'sensor.firefly_iii_test_credit_card',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-250.00',
})
# ---
# name: test_all_entities[sensor.firefly_iii_test_lunch-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.firefly_iii_test_lunch',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': None,
'original_name': 'Lunch',
'platform': 'firefly_iii',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'category',
'unique_id': 'None_category_2',
'unit_of_measurement': 'AMS',
})
# ---
# name: test_all_entities[sensor.firefly_iii_test_lunch-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'monetary',
'friendly_name': 'Firefly III test Lunch',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'AMS',
}),
'context': <ANY>,
'entity_id': 'sensor.firefly_iii_test_lunch',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-12300.0',
})
# ---
# name: test_all_entities[sensor.firefly_iii_test_my_checking_account-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.firefly_iii_test_my_checking_account',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': 'mdi:account-cash',
'original_name': 'My checking account',
'platform': 'firefly_iii',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'account',
'unique_id': 'None_account_type_2',
'unit_of_measurement': 'AMS',
})
# ---
# name: test_all_entities[sensor.firefly_iii_test_my_checking_account-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'account_role': 'defaultAsset',
'account_type': 'asset',
'current_balance': '123.45',
'device_class': 'monetary',
'friendly_name': 'Firefly III test My checking account',
'icon': 'mdi:account-cash',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'AMS',
}),
'context': <ANY>,
'entity_id': 'sensor.firefly_iii_test_my_checking_account',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '123.45',
})
# ---
# name: test_all_entities[sensor.firefly_iii_test_savings_account-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.firefly_iii_test_savings_account',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.MONETARY: 'monetary'>,
'original_icon': 'mdi:cash-minus',
'original_name': 'Savings Account',
'platform': 'firefly_iii',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'account',
'unique_id': 'None_account_type_3',
'unit_of_measurement': 'AMS',
})
# ---
# name: test_all_entities[sensor.firefly_iii_test_savings_account-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'account_role': 'savingsAsset',
'account_type': 'expense',
'current_balance': '5000.00',
'device_class': 'monetary',
'friendly_name': 'Firefly III test Savings Account',
'icon': 'mdi:cash-minus',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'AMS',
}),
'context': <ANY>,
'entity_id': 'sensor.firefly_iii_test_savings_account',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5000.00',
})
# ---

View File

@@ -0,0 +1,134 @@
"""Test the Firefly III config flow."""
from unittest.mock import AsyncMock, MagicMock
from pyfirefly.exceptions import (
FireflyAuthenticationError,
FireflyConnectionError,
FireflyTimeoutError,
)
import pytest
from homeassistant.components.firefly_iii.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import MOCK_TEST_CONFIG
from tests.common import MockConfigEntry
MOCK_USER_SETUP = {
CONF_URL: "https://127.0.0.1:8080/",
CONF_API_KEY: "test_api_key",
CONF_VERIFY_SSL: True,
}
async def test_form_and_flow(
hass: HomeAssistant,
mock_firefly_client: MagicMock,
mock_setup_entry: MagicMock,
) -> None:
"""Test we get the form and can complete the flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_USER_SETUP,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "https://127.0.0.1:8080/"
assert result["data"] == MOCK_TEST_CONFIG
@pytest.mark.parametrize(
("exception", "reason"),
[
(
FireflyAuthenticationError,
"invalid_auth",
),
(
FireflyConnectionError,
"cannot_connect",
),
(
FireflyTimeoutError,
"timeout_connect",
),
(
Exception("Some other error"),
"unknown",
),
],
)
async def test_form_exceptions(
hass: HomeAssistant,
mock_firefly_client: AsyncMock,
mock_setup_entry: MagicMock,
exception: Exception,
reason: str,
) -> None:
"""Test we handle all exceptions."""
mock_firefly_client.get_about.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_USER_SETUP,
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": reason}
mock_firefly_client.get_about.side_effect = None
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_USER_SETUP,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "https://127.0.0.1:8080/"
assert result["data"] == MOCK_TEST_CONFIG
async def test_duplicate_entry(
hass: HomeAssistant,
mock_firefly_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test we handle duplicate entries."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=MOCK_USER_SETUP,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@@ -0,0 +1,31 @@
"""Tests for the Firefly III sensor platform."""
from unittest.mock import AsyncMock, patch
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_firefly_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
with patch(
"homeassistant.components.firefly_iii._PLATFORMS",
[Platform.SENSOR],
):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry.entry_id
)