Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor entities and migrate unique IDs #55

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,8 @@ repos:
# shell.
- id: mypy
name: mypy
entry: mypy
entry: ./script/run-in-venv.sh mypy
language: python
types_or: [python, pyi]
args:
- --ignore-missing-imports
require_serial: true
files: ^(custom_components)/.+\.(py|pyi)$
26 changes: 13 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ Cependant, certaines sondes peuvent avoir de la valeur dans leur "instantanéit

Suivant la configuration que vous choisirez pour votre installation vous trouverez dans ce fichier dans la liste des sondes avec les annotations suivantes:

- <sup>1</sup> sonde compatible avec le mode temps réel: si celui-ci est activé par l'utilisateur, les mises à jours seront bien plus fréquentes (dès qu'elles sont lues sur la connection série)
- <sup>2</sup> sonde dont le mode temps réel est forcé même si l'utilisateur n'a pas activé le mode temps réèl dans le cas où la valeur de la sonde est importante et/ou éphémère
- `<sup>`1`</sup>` sonde compatible avec le mode temps réel: si celui-ci est activé par l'utilisateur, les mises à jours seront bien plus fréquentes (dès qu'elles sont lues sur la connection série)
- `<sup>`2`</sup>` sonde dont le mode temps réel est forcé même si l'utilisateur n'a pas activé le mode temps réèl dans le cas où la valeur de la sonde est importante et/ou éphémère

### Mode historique

Expand All @@ -62,10 +62,10 @@ Les 23 champs des compteurs mono-phasé configurés en mode historique sont supp
- `PEJP` Préavis Début EJP (30 min)
- `PTEC` Période Tarifaire en cours
- `DEMAIN` Couleur du lendemain
- `IINST` Intensité Instantanée <sup>1</sup>
- `ADPS` Avertissement de Dépassement De Puissance Souscrite <sup>2</sup>
- `IINST` Intensité Instantanée `<sup>`1`</sup>`
- `ADPS` Avertissement de Dépassement De Puissance Souscrite `<sup>`2`</sup>`
- `IMAX` Intensité maximale appelée
- `PAPP` Puissance apparente <sup>1</sup>
- `PAPP` Puissance apparente `<sup>`1`</sup>`
- `HHPHC` Horaire Heures Pleines Heures Creuses
- `MOTDETAT` Mot d'état du compteur

Expand All @@ -92,23 +92,23 @@ Des retours de log en `DEBUG` pendant l'émission de trames courtes sont nécess
- `PEJP` Préavis Début EJP (30 min)
- `PTEC` Période Tarifaire en cours
- `DEMAIN` Couleur du lendemain
- `IINST1` Intensité Instantanée (phase 1) <sup>1</sup> pour les trames longues <sup>2</sup> pour les trames courtes
- `IINST2` Intensité Instantanée (phase 2) <sup>1</sup> pour les trames longues <sup>2</sup> pour les trames courtes
- `IINST3` Intensité Instantanée (phase 3) <sup>1</sup> pour les trames longues <sup>2</sup> pour les trames courtes
- `IINST1` Intensité Instantanée (phase 1) `<sup>`1`</sup>` pour les trames longues `<sup>`2`</sup>` pour les trames courtes
- `IINST2` Intensité Instantanée (phase 2) `<sup>`1`</sup>` pour les trames longues `<sup>`2`</sup>` pour les trames courtes
- `IINST3` Intensité Instantanée (phase 3) `<sup>`1`</sup>` pour les trames longues `<sup>`2`</sup>` pour les trames courtes
- `IMAX1` Intensité maximale (phase 1)
- `IMAX2` Intensité maximale (phase 2)
- `IMAX3` Intensité maximale (phase 3)
- `PMAX` Puissance maximale triphasée atteinte
- `PAPP` Puissance apparente <sup>1</sup>
- `PAPP` Puissance apparente `<sup>`1`</sup>`
- `HHPHC` Horaire Heures Pleines Heures Creuses
- `MOTDETAT` Mot d'état du compteur
- `ADIR1` Avertissement de Dépassement d'intensité de réglage (phase 1) <sup>2</sup> trames courtes uniquement
- `ADIR2` Avertissement de Dépassement d'intensité de réglage (phase 2) <sup>2</sup> trames courtes uniquement
- `ADIR3` Avertissement de Dépassement d'intensité de réglage (phase 3) <sup>2</sup> trames courtes uniquement
- `ADIR1` Avertissement de Dépassement d'intensité de réglage (phase 1) `<sup>`2`</sup>` trames courtes uniquement
- `ADIR2` Avertissement de Dépassement d'intensité de réglage (phase 2) `<sup>`2`</sup>` trames courtes uniquement
- `ADIR3` Avertissement de Dépassement d'intensité de réglage (phase 3) `<sup>`2`</sup>` trames courtes uniquement

### Mode standard

Une beta est actuellement en cours pour la future v3 supportant le mode standard, vous la trouverez dans les [releases](https://github.com/hekmon/linkytic/releases). N'hésitez pas à faire vos retours dans [#19](https://github.com/hekmon/linkytic/pull/19) afin d'accélére la sortie de beta du mode standard !
Une beta est actuellement en cours pour la future v3 supportant le mode standard, vous la trouverez dans les [releases](https://github.com/hekmon/linkytic/releases). N'hésitez pas à faire vos retours dans [#19](https://github.com/hekmon/linkytic/pull/19) afin d'accélére la sortie de beta du mode standard ! Si vous rencontrez un bug, vous pouvez aussi ouvrir une [issue](https://github.com/hekmon/linkytic/issues).

## Installation

Expand Down
187 changes: 146 additions & 41 deletions custom_components/linkytic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from homeassistant.util import slugify

from .const import (
DOMAIN,
Expand All @@ -28,22 +30,24 @@
_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry[LinkyTICReader]
) -> bool:
"""Set up linkytic from a config entry."""
# Create the serial reader thread and start it
port = entry.data.get(SETUP_SERIAL)
port = entry.data[SETUP_SERIAL]
try:
serial_reader = LinkyTICReader(
title=entry.title,
port=port,
std_mode=entry.data.get(SETUP_TICMODE) == TICMODE_STANDARD,
producer_mode=entry.data.get(SETUP_PRODUCER),
three_phase=entry.data.get(SETUP_THREEPHASE),
std_mode=entry.data[SETUP_TICMODE] == TICMODE_STANDARD,
producer_mode=entry.data[SETUP_PRODUCER],
three_phase=entry.data[SETUP_THREEPHASE],
real_time=entry.options.get(OPTIONS_REALTIME),
)
serial_reader.start()

async def read_serial_number(serial: LinkyTICReader):
async def read_serial_number(serial: LinkyTICReader) -> str:
while serial.serial_number is None:
await asyncio.sleep(1)
# Check for any serial error that occurred in the serial thread context
Expand All @@ -65,56 +69,52 @@ async def read_serial_number(serial: LinkyTICReader):
"Connected to serial port but coulnd't read serial number before timeout: check if TIC is connected and active."
) from e

# entry.unique_id is the serial number read during the config flow, all data correspond to this meter s/n
if s_n != entry.unique_id:
serial_reader.signalstop("serial_number_mismatch")
raise ConfigEntryError(
f"Connected to a different meter with S/N: `{s_n}`, expected `{entry.unique_id}`. "
"Aborting setup to prevent overwriting long term data."
)

_LOGGER.info(f"Device connected with serial number: {s_n}")

hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, serial_reader.signalstop)
# Add options callback
entry.async_on_unload(entry.add_update_listener(update_listener))
entry.async_on_unload(lambda: serial_reader.signalstop("config_entry_unload"))
# Add the serial reader to HA and initialize sensors
try:
hass.data[DOMAIN][entry.entry_id] = serial_reader
except KeyError:
hass.data[DOMAIN] = {}
hass.data[DOMAIN][entry.entry_id] = serial_reader

entry.runtime_data = serial_reader

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, entry: ConfigEntry[LinkyTICReader]
) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
# Remove the related entry
hass.data[DOMAIN].pop(entry.entry_id)
reader = entry.runtime_data
reader.signalstop("unload")
return unload_ok


async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
# Retrieved the serial reader for this config entry
try:
serial_reader = hass.data[DOMAIN][entry.entry_id]
except KeyError:
_LOGGER.error(
"Can not update options for %s: failed to get the serial reader object",
entry.title,
)
return
# Update its options
serial_reader.update_options(entry.options.get(OPTIONS_REALTIME))

reader = entry.runtime_data
reader.update_options(entry.options.get(OPTIONS_REALTIME))


async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry):
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.info(
"Migrating from version %d.%d", config_entry.version, config_entry.minor_version
)
_LOGGER.info("Migrating from version %d.%d", entry.version, entry.minor_version)

if config_entry.version == 1:
new = {**config_entry.data}
if entry.version == 1:
new = {**entry.data}

if config_entry.minor_version < 2:
if entry.minor_version < 2:
# Migrate to serial by-id.
serial_by_id = await hass.async_add_executor_job(
usb.get_serial_by_id, new[SETUP_SERIAL]
Expand All @@ -127,14 +127,119 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry):
else:
new[SETUP_SERIAL] = serial_by_id

# config_entry.minor_version = 2
# Migrate the unique ID to use the serial number, this is not backward compatible
try:
reader = LinkyTICReader(
title=entry.title,
port=new[SETUP_SERIAL],
std_mode=new[SETUP_TICMODE] == TICMODE_STANDARD,
producer_mode=new[SETUP_PRODUCER],
three_phase=new[SETUP_THREEPHASE],
)
reader.start()

async def read_serial_number(serial: LinkyTICReader) -> str:
while serial.serial_number is None:
await asyncio.sleep(1)
# Check for any serial error that occurred in the serial thread context
if serial.setup_error:
raise serial.setup_error
return serial.serial_number

s_n = await asyncio.wait_for(read_serial_number(reader), timeout=5)

except (*LINKY_IO_ERRORS, TimeoutError) as e:
_LOGGER.error(
"Error migrating config entry to version 2, could not read device serial number: (%s)",
e,
)
_LOGGER.warning("Restart Home Assistant to retry migration")
return False

finally:
reader.signalstop("probe_end")

serial_number = slugify(s_n)

# Explicitly pass serial number as config entry is not updated yet
await _migrate_entities_unique_id(hass, entry, serial_number)

hass.config_entries.async_update_entry(
config_entry, data=new, minor_version=2, version=1
) # type: ignore
entry, data=new, version=2, minor_version=0, unique_id=serial_number
)

_LOGGER.info(
"Migration to version %d.%d successful",
config_entry.version,
config_entry.minor_version,
entry.version,
entry.minor_version,
)
return True


async def _migrate_entities_unique_id(
hass: HomeAssistant, entry: ConfigEntry, serial_number: str
) -> None:
"""Migrate entities unique id to conform to HA specifications."""

# Old entries are of format f"{DOMAIN}_{entry.config_id}_suffix"
# which is not conform to HA unique ID requirements (https://developers.home-assistant.io/docs/entity_registry_index#unique-id-requirements)
# domain should not appear in the unique id
# ConfigEntry.config_id is a last resort unique id when no acceptable source is awailable
# the meter serial number is a valid (and better) device unique id

# Since we are migrating unique id, might as well migrate some suffixes for consistency
_ENTITY_MIGRATION_SUFFIX = {
# non-standard tic tags that were implemented as different sensors
"smaxn": "smaxsn",
"smaxn-1": "smaxsn-1",
# status register sensors, due to field renaming
"contact_sec": "dry_contact",
"organe_de_coupure": "trip_unit",
"etat_du_cache_borne_distributeur": "terminal_cover",
"surtension_sur_une_des_phases": "overvoltage",
"depassement_puissance_reference": "power_over_ref",
"producteur_consommateur": "producer",
"sens_energie_active": "injecting",
"tarif_contrat_fourniture": "provider_index",
"tarif_contrat_distributeur": "distributor_index",
"mode_degrade_horloge": "rtc_degraded",
"mode_tic": "tic_std",
"etat_sortie_communication_euridis": "euridis",
"synchro_cpl": "cpl_sync",
"status_cpl": "cpl_status",
"couleur_jour_contrat_tempo": "color_today",
"couleur_lendemain_contrat_tempo": "color_next_day",
"preavis_pointes_mobiles": "mobile_peak_notice",
"pointe_mobile": "mobile_peak",
}

entity_reg = er.async_get(hass)
entities = er.async_entries_for_config_entry(entity_reg, entry.entry_id)

migrate_entities: dict[str, str] = {}

for entity in entities:
old_unique_id = entity.unique_id

# Old ids all start with `linkytic_`
if not old_unique_id.startswith(DOMAIN):
continue

# format `linkytic_ENTRYID_suffix
old_suffix = old_unique_id.split(entry.entry_id + "_", maxsplit=1)[1]

if (new_suffix := _ENTITY_MIGRATION_SUFFIX.get(old_suffix)) is None:
# entity is not in the migration table, just remove the domain prefix and update the entry_id
new_suffix = old_suffix

migrate_entities[entity.entity_id] = slugify(f"{serial_number}_{new_suffix}")

_LOGGER.debug(
"Updating entity %s from unique id `%s` to `%s`",
entity.entity_id,
old_unique_id,
migrate_entities[entity.entity_id],
)

for entity_id, unique_id in migrate_entities.items():
entity_reg.async_update_entity(entity_id, new_unique_id=unique_id)
Loading
Loading