diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index d4bf96d..5bb6884 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -4,7 +4,8 @@ on: push: branches: - dev - - master + - master + - release/* pull_request: schedule: - cron: "0 0 * * *" @@ -36,37 +37,3 @@ jobs: uses: "hacs/action@main" with: category: "integration" - - release: - if: ${{ startsWith(github.ref, 'refs/tags/v') }} - needs: validate - runs-on: "ubuntu-latest" - steps: - - uses: actions/checkout@v4 - - name: Zip custom components dir - working-directory: "custom_components/huesyncbox" - run: zip -r huesyncbox.zip ./* - - name: Create Release - id: create_release - uses: actions/create-release@v1 # Official Github action - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token - with: - tag_name: ${{ github.ref }} - release_name: ${{ github.ref }} -# body: | -# Changes in this Release -# - First Change -# - Second Change - draft: true - prerelease: false - - name: Upload Release Asset - id: upload-release-asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps - asset_path: ./custom_components/huesyncbox/huesyncbox.zip - asset_name: huesyncbox.zip - asset_content_type: application/zip diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..8603c64 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,38 @@ +name: Create release from tag + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + +jobs: + release: + runs-on: "ubuntu-latest" + steps: + - uses: actions/checkout@v4 + + - run: | + echo Checkout done for $GITHUB_REF + + - name: Figure out integration name + run: | + INTEGRATION_SUBDIR=$(ls -d custom_components/*/) + echo "INTEGRATION_NAME=$(echo $INTEGRATION_SUBDIR | cut -d'/' -f2)" >> $GITHUB_ENV + echo Integration name: ${{ env.INTEGRATION_NAME }} + + - name: Zip custom components dir + working-directory: "custom_components/${{ env.INTEGRATION_NAME }}" + run: zip -r ${{ env.INTEGRATION_NAME }}.zip ./* + + - name: Create Release + # Started from: https://stackoverflow.com/questions/75679683/how-can-i-auto-generate-a-release-note-and-create-a-release-using-github-actions + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ github.ref_name }} + # --discussion-category "General" not possible for draft releases :/ + run: | + gh release create "$tag" "./custom_components/${{ env.INTEGRATION_NAME }}/${{ env.INTEGRATION_NAME }}.zip" \ + --repo="$GITHUB_REPOSITORY" \ + --title="$tag" \ + --generate-notes \ + --draft diff --git a/README.md b/README.md index 0718f67..970c273 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ [![Contributors](https://img.shields.io/github/contributors/mvdwetering/huesyncbox.svg)](https://github.com/mvdwetering/huesyncbox/graphs/contributors) -Custom integration for the Philips Hue Play HDMI Sync Box. +Custom integration for the Philips Hue Play HDMI Sync Box. +Both 4K and 8K are supported. - [About](#about) - [Behavior](#behavior) @@ -30,7 +31,7 @@ The following features are available: * Brightness control * Entertainment area selection * HDMI input connection status -* Dolby Vision compatibility on/off +* Dolby Vision compatibility on/off (only on 4K) * LED indicator mode * Bridge connection status ⁺ * Bridge ID ⁺ @@ -57,7 +58,7 @@ For the parameter descriptions use the Actions tab in the Home Assistant Develop | Action name | Description | |---|---| | set_bridge | Set the bridge to be used by the Philips Hue Play HDMI Syncbox. | -| set_sync_state | Set the state of multiple features of the Philips Hue Play HDMI Syncbox at once. Makes sure everything is set in the correct order and is more efficient compared to using separate commands. | +| set_sync_state | Set the state of multiple features of the Philips Hue Play HDMI Syncbox at once. Makes sure everything is set in the correct order and is more efficient than using separate commands. | ## Updating from before version 2.0 diff --git a/custom_components/huesyncbox/icons.json b/custom_components/huesyncbox/icons.json index a31f053..9946dde 100644 --- a/custom_components/huesyncbox/icons.json +++ b/custom_components/huesyncbox/icons.json @@ -1,6 +1,86 @@ { - "services": { - "set_bridge": "mdi:bridge", - "set_sync_state": "mdi:sync" + "entity": { + "number": { + "brightness": { + "default": "mdi:brightness-5" + } + }, + "select": { + "hdmi_input": { + "default": "mdi:hdmi-port" + }, + "entertainment_area": { + "default": "mdi:lamps" + }, + "intensity": { + "default": "mdi:sine-wave" + }, + "sync_mode": { + "default": "mdi:multimedia" + }, + "led_indicator_mode": { + "default": "mdi:led-variant-on", + "state": { + "off": "mdi:led-variant-off", + "dimmed": "mdi:led-variant-on", + "normal": "mdi:led-on" + } + } + }, + "sensor": { + "bridge_connection_state": { + "default": "mdi:connection" + }, + "bridge_unique_id": { + "default": "mdi:bridge" + }, + "hdmi1_status": { + "default": "mdi:video-input-hdmi" + }, + "hdmi2_status": { + "default": "mdi:video-input-hdmi" + }, + "hdmi3_status": { + "default": "mdi:video-input-hdmi" + }, + "hdmi4_status": { + "default": "mdi:video-input-hdmi" + }, + "ip_address": { + "default": "mdi:ip-network" + }, + "wifi_strength": { + "default": "mdi:wifi-strength-outline", + "state": { + "not_connected": "mdi:wifi-strength-off-outline", + "weak": "mdi:wifi-strength-1", + "fair": "mdi:wifi-strength-2", + "good": "mdi:wifi-strength-3", + "excellent": "mdi:wifi-strength-4" + } + }, + "content_info": { + "default": "mdi:aspect-ratio" + } + }, + "switch": { + "power": { + "default": "mdi:power" + }, + "light_sync": { + "default": "mdi:television-ambient-light" + }, + "dolby_vision_compatibility": { + "default": "mdi:hdr" + } } -} \ No newline at end of file + }, + "services": { + "set_bridge": { + "service": "mdi:bridge" + }, + "set_sync_state": { + "service": "mdi:sync" + } + } +} diff --git a/custom_components/huesyncbox/manifest.json b/custom_components/huesyncbox/manifest.json index 1ad6e85..f574d64 100644 --- a/custom_components/huesyncbox/manifest.json +++ b/custom_components/huesyncbox/manifest.json @@ -16,8 +16,8 @@ "requirements": [ "aiohuesyncbox==0.0.30" ], - "version": "2.2.4", + "version": "2.3.0", "zeroconf": [ "_huesync._tcp.local." ] -} +} \ No newline at end of file diff --git a/custom_components/huesyncbox/number.py b/custom_components/huesyncbox/number.py index d4d0472..7bc4697 100644 --- a/custom_components/huesyncbox/number.py +++ b/custom_components/huesyncbox/number.py @@ -27,7 +27,6 @@ async def set_brightness(api: aiohuesyncbox.HueSyncBox, brightness): ENTITY_DESCRIPTIONS = [ HueSyncBoxNumberEntityDescription( # type: ignore key="brightness", # type: ignore - icon="mdi:brightness-5", # type: ignore native_max_value=100, # type: ignore native_min_value=1, # type: ignore native_step=1, # type: ignore diff --git a/custom_components/huesyncbox/select.py b/custom_components/huesyncbox/select.py index df4d0be..3efd288 100644 --- a/custom_components/huesyncbox/select.py +++ b/custom_components/huesyncbox/select.py @@ -118,14 +118,12 @@ async def select_led_indicator_mode(api: aiohuesyncbox.HueSyncBox, mode): ENTITY_DESCRIPTIONS = [ HueSyncBoxSelectEntityDescription( # type: ignore key="hdmi_input", # type: ignore - icon="mdi:hdmi-port", # type: ignore options_fn=available_inputs, current_option_fn=current_input, select_option_fn=select_input, ), HueSyncBoxSelectEntityDescription( # type: ignore key="entertainment_area", # type: ignore - icon="mdi:lamps", # type: ignore entity_category=EntityCategory.CONFIG, # type: ignore options_fn=available_entertainment_areas, current_option_fn=current_entertainment_area, @@ -133,7 +131,6 @@ async def select_led_indicator_mode(api: aiohuesyncbox.HueSyncBox, mode): ), HueSyncBoxSelectEntityDescription( # type: ignore key="intensity", # type: ignore - icon="mdi:sine-wave", # type: ignore options=INTENSITIES, # type: ignore current_option_fn=current_intensity, select_option_fn=select_intensity, @@ -146,7 +143,6 @@ async def select_led_indicator_mode(api: aiohuesyncbox.HueSyncBox, mode): ), HueSyncBoxSelectEntityDescription( # type: ignore key="led_indicator_mode", # type: ignore - icon="mdi:alarm-light", # type: ignore entity_category=EntityCategory.CONFIG, # type: ignore options=sorted(LED_INDICATOR_MODES), # type: ignore current_option_fn=current_led_indicator_mode, diff --git a/custom_components/huesyncbox/sensor.py b/custom_components/huesyncbox/sensor.py index 1f11d3c..f933cff 100644 --- a/custom_components/huesyncbox/sensor.py +++ b/custom_components/huesyncbox/sensor.py @@ -18,7 +18,6 @@ @dataclass(frozen=True, kw_only=True) class HueSyncBoxSensorEntityDescription(SensorEntityDescription): get_value: Callable[[aiohuesyncbox.HueSyncBox], str] = None # type: ignore[assignment] - icons: dict[str, str] | None = None WIFI_STRENGTH_STATES = { @@ -29,34 +28,32 @@ class HueSyncBoxSensorEntityDescription(SensorEntityDescription): 4: "excellent", } -WIFI_STRENGTH_ICONS = { - "not_connected": "mdi:wifi-strength-off-outline", - "weak": "mdi:wifi-strength-1", - "fair": "mdi:wifi-strength-2", - "good": "mdi:wifi-strength-3", - "excellent": "mdi:wifi-strength-4", -} - ENTITY_DESCRIPTIONS = [ HueSyncBoxSensorEntityDescription( # type: ignore key="bridge_connection_state", # type: ignore - icon="mdi:connection", # type: ignore entity_category=EntityCategory.DIAGNOSTIC, # type: ignore entity_registry_enabled_default=False, # type: ignore device_class=SensorDeviceClass.ENUM, # type: ignore - options=["uninitialized", "disconnected", "connecting", "unauthorized", "connected", "invalidgroup", "streaming", "busy"], # type: ignore + options=[ + "uninitialized", + "disconnected", + "connecting", + "unauthorized", + "connected", + "invalidgroup", + "streaming", + "busy", + ], # type: ignore get_value=lambda api: api.hue.connection_state, ), HueSyncBoxSensorEntityDescription( # type: ignore key="bridge_unique_id", # type: ignore - icon="mdi:bridge", # type: ignore entity_category=EntityCategory.DIAGNOSTIC, # type: ignore entity_registry_enabled_default=False, # type: ignore get_value=lambda api: api.hue.bridge_unique_id, ), HueSyncBoxSensorEntityDescription( # type: ignore key="hdmi1_status", # type: ignore - icon="mdi:video-input-hdmi", # type: ignore entity_category=EntityCategory.DIAGNOSTIC, # type: ignore device_class=SensorDeviceClass.ENUM, # type: ignore options=["unplugged", "plugged", "linked", "unknown"], # type: ignore @@ -64,7 +61,6 @@ class HueSyncBoxSensorEntityDescription(SensorEntityDescription): ), HueSyncBoxSensorEntityDescription( # type: ignore key="hdmi2_status", # type: ignore - icon="mdi:video-input-hdmi", # type: ignore entity_category=EntityCategory.DIAGNOSTIC, # type: ignore device_class=SensorDeviceClass.ENUM, # type: ignore options=["unplugged", "plugged", "linked", "unknown"], # type: ignore @@ -72,7 +68,6 @@ class HueSyncBoxSensorEntityDescription(SensorEntityDescription): ), HueSyncBoxSensorEntityDescription( # type: ignore key="hdmi3_status", # type: ignore - icon="mdi:video-input-hdmi", # type: ignore entity_category=EntityCategory.DIAGNOSTIC, # type: ignore device_class=SensorDeviceClass.ENUM, # type: ignore options=["unplugged", "plugged", "linked", "unknown"], # type: ignore @@ -80,7 +75,6 @@ class HueSyncBoxSensorEntityDescription(SensorEntityDescription): ), HueSyncBoxSensorEntityDescription( # type: ignore key="hdmi4_status", # type: ignore - icon="mdi:video-input-hdmi", # type: ignore entity_category=EntityCategory.DIAGNOSTIC, # type: ignore device_class=SensorDeviceClass.ENUM, # type: ignore options=["unplugged", "plugged", "linked", "unknown"], # type: ignore @@ -88,22 +82,18 @@ class HueSyncBoxSensorEntityDescription(SensorEntityDescription): ), HueSyncBoxSensorEntityDescription( # type: ignore key="ip_address", # type: ignore - icon="mdi:ip-network", # type: ignore entity_category=EntityCategory.DIAGNOSTIC, # type: ignore entity_registry_enabled_default=False, # type: ignore get_value=lambda api: api.device.ip_address, ), HueSyncBoxSensorEntityDescription( # type: ignore key="wifi_strength", # type: ignore - # icon="mdi:wifi", # type: ignore - icons=WIFI_STRENGTH_ICONS, entity_category=EntityCategory.DIAGNOSTIC, # type: ignore entity_registry_enabled_default=False, # type: ignore get_value=lambda api: WIFI_STRENGTH_STATES[api.device.wifi.strength], # type: ignore ), HueSyncBoxSensorEntityDescription( # type: ignore key="content_info", # type: ignore - icon="mdi:aspect-ratio", # type: ignore entity_category=EntityCategory.DIAGNOSTIC, # type: ignore entity_registry_enabled_default=False, # type: ignore get_value=lambda api: api.hdmi.content_specs, # type: ignore @@ -154,12 +144,3 @@ def __init__( def native_value(self) -> str | None: """Return the state of the sensor.""" return self.entity_description.get_value(self.coordinator.api) - - @property - def icon(self) -> str | None: - """Return the icon.""" - if self.entity_description.icons is not None: - return self.entity_description.icons[ - self.entity_description.get_value(self.coordinator.api) - ] - return super().icon diff --git a/custom_components/huesyncbox/switch.py b/custom_components/huesyncbox/switch.py index c3113d4..9ad6a16 100644 --- a/custom_components/huesyncbox/switch.py +++ b/custom_components/huesyncbox/switch.py @@ -24,21 +24,18 @@ class HueSyncBoxSwitchEntityDescription(SwitchEntityDescription): ENTITY_DESCRIPTIONS = [ HueSyncBoxSwitchEntityDescription( # type: ignore key="power", # type: ignore - icon="mdi:power", # type: ignore is_on=lambda api: api.execution.mode != "powersave", turn_on=lambda api: api.execution.set_state(mode="passthrough"), turn_off=lambda api: api.execution.set_state(mode="powersave"), ), HueSyncBoxSwitchEntityDescription( # type: ignore key="light_sync", # type: ignore - icon="mdi:television-ambient-light", # type: ignore is_on=lambda api: api.execution.mode not in ["powersave", "passthrough"], turn_on=lambda api: api.execution.set_state(sync_active=True), turn_off=lambda api: api.execution.set_state(sync_active=False), ), HueSyncBoxSwitchEntityDescription( # type: ignore key="dolby_vision_compatibility", # type: ignore - icon="mdi:hdr", # type: ignore entity_category=EntityCategory.CONFIG, # type: ignore is_on=lambda api: api.behavior.force_dovi_native == 1, turn_on=lambda api: api.behavior.set_force_dovi_native(1), diff --git a/custom_components/huesyncbox/translations/fr.json b/custom_components/huesyncbox/translations/fr.json new file mode 100644 index 0000000..dce0dab --- /dev/null +++ b/custom_components/huesyncbox/translations/fr.json @@ -0,0 +1,236 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est déjà configuré", + "reauth_successful": "Réauthentification de la Philips Hue Play HDMI Sync Box réussie", + "reconfigure_successful": "Reconfiguration de la Philips Hue Play HDMI Sync Box réussie", + "connection_failed": "Échec de la configuration" + }, + "error": { + "cannot_connect": "Échec de la connexion", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "configure": { + "title": "Entrer les informations de l'appareil", + "description": "Les détails peuvent être trouvés dans l'application principale Hue.\n\nSélectionnez l'onglet Sync et assurez-vous que la Philips Hue Play HDMI Sync Box est sélectionné. Ensuite, appuyez sur le menu … en haut, puis sélectionnez Appareil, puis Informations relatives au réseau pour l'adresse IP et Info Appareil pour l'identifiant.", + "data": { + "host": "Adresse IP (ex. 192.168.1.123)", + "unique_id": "Identifiant (ex. C42996000000)" + } + }, + "reauth_confirm": { + "title": "Réauthentifier l'intégration", + "description": "La Philips Hue Play HDMI Sync Box doit être lié à nouveau" + }, + "zeroconf_confirm": { + "title": "Appareil trouvé", + "description": "La Philips Hue Play HDMI Sync Box doit être lié. Appuyez sur suivant pour commencer le processus de liaison." + } + }, + "progress": { + "wait_for_button": "Appuyez et maintenez le bouton de la Philips Hue Play HDMI Sync Box pendant quelques secondes jusqu'à ce qu'elle clignote en vert pour la lier." + } + }, + "entity": { + "number": { + "brightness": { + "name": "Luminosité" + } + }, + "select": { + "hdmi_input": { + "name": "Sortie HDMI" + }, + "entertainment_area": { + "name": "Espace de divertissement" + }, + "intensity": { + "name": "Intensité", + "state": { + "subtle": "Subtile", + "moderate": "Moderé", + "high": "Elevé", + "intense": "Intense" + } + }, + "led_indicator_mode": { + "name": "Voyant LED", + "state": { + "normal": "Normal", + "off": "Éteint", + "dimmed": "Atténué" + } + }, + "sync_mode": { + "name": "Mode de synchronisation", + "state": { + "video": "Vidéo", + "music": "Musique", + "game": "Jeu" + } + } + }, + "sensor": { + "bridge_unique_id": { + "name": "ID du bridge" + }, + "ip_address": { + "name": "Adresse IP" + }, + "bridge_connection_state": { + "name": "Connection au pont", + "state": { + "uninitialized": "Non initialisé", + "disconnected": "Déconnecté", + "connecting": "Connexion en cours", + "unauthorized": "Non autorisé", + "connected": "Connecté", + "invalidgroup": "Groupe invalide", + "streaming": "Diffusion en cours", + "busy": "Occupé" + } + }, + "hdmi1_status": { + "name": "État HDMI1", + "state": { + "unplugged": "Débranché", + "plugged": "Branché", + "linked": "Connecté", + "unknown": "Inconnu" + } + }, + "hdmi2_status": { + "name": "État HDMI2", + "state": { + "unplugged": "Débranché", + "plugged": "Branché", + "linked": "Connecté", + "unknown": "Inconnu" + } + }, + "hdmi3_status": { + "name": "État HDMI3", + "state": { + "unplugged": "Débranché", + "plugged": "Branché", + "linked": "Connecté", + "unknown": "Inconnu" + } + }, + "hdmi4_status": { + "name": "État HDMI4", + "state": { + "unplugged": "Débranché", + "plugged": "Branché", + "linked": "Connecté", + "unknown": "Inconnu" + } + }, + "wifi_strength": { + "name": "Qualité du Wi-Fi", + "state": { + "not_connected": "Non connecté", + "weak": "Faible", + "fair": "Convenable", + "good": "Bon", + "excellent": "Excellent" + } + }, + "content_info": { + "name": "Infos sur le contenu" + } + }, + "switch": { + "power": { + "name": "Alimentation" + }, + "light_sync": { + "name": "Synchronisation des lumières" + }, + "dolby_vision_compatibility": { + "name": "Compatibilité Dolby Vision" + } + } + }, + "selector": { + "modes": { + "options": { + "video": "Vidéo", + "music": "Musique", + "game": "Jeu" + } + }, + "intensities": { + "options": { + "subtle": "Subtile", + "moderate": "Modéré", + "high": "Elevé", + "intense": "Intense" + } + }, + "inputs": { + "options": { + "input1": "HDMI 1", + "input2": "HDMI 2", + "input3": "HDMI 3", + "input4": "HDMI 4" + } + } + }, + "services": { + "set_bridge": { + "name": "Configurer le pont", + "description": "Configurer le pont à utiliser avec la Philips Hue Play HDMI Sync Box. Gardez à l'esprit que le changement de pont par la box prend un certain temps (environ 15 secondes). Après avoir changé le pont, vous devrez peut-être sélectionner la `zone_de_divertissement` si l'état de connexion est `Groupe invalide` au lieu de `Connecté`.", + "fields": { + "bridge_id": { + "name": "ID du Pont", + "description": "ID du pont. Un code hexadécimal de 16 caractères." + }, + "bridge_username": { + "name": "Nom d'utilisateur", + "description": "Nom d'utilisateur (également appelé clé d'application) valide pour le pont. Un long code de caractères aléatoires." + }, + "bridge_clientkey": { + "name": "Clé Client", + "description": "Clé client associée au nom d'utilisateur. Un code hexadécimal de 32 caractères." + } + } + }, + "set_sync_state": { + "name": "Définir l'état de synchronisation des lumières", + "description": "Contrôler complètement la synchronisation des lumières de la Philips Hue Play HDMI Sync Box avec une seule commande.", + "fields": { + "power": { + "name": "Alimentation", + "description": "Allumer ou éteindre le boîtier." + }, + "sync": { + "name": "Synchronisation de la lumière", + "description": "Activer ou désactiver la synchronisation de la lumière. L'activer allumera également le boîtier." + }, + "brightness": { + "name": "Luminosité", + "description": "Sélectionner la luminosité." + }, + "mode": { + "name": "Mode", + "description": "Selectionner le mode. L'activation du mode allumera également le boîtier et démarrera la synchronisation des lumières." + }, + "intensity": { + "name": "Intensité", + "description": "Sélectionner l'intensité." + }, + "input": { + "name": "Entrée", + "description": "Sélectionner l'entrée." + }, + "entertainment_area": { + "name": "Espace de divertissement", + "description": "Sélectionner l'espace de divertissement. Le nom doit être _exact_" + } + } + } + } +} diff --git a/release.py b/release.py new file mode 100755 index 0000000..1e749be --- /dev/null +++ b/release.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +"""Helper script for release related tasks.""" + +import argparse +from enum import Enum +import logging +import subprocess +import json +import os +import re + +from awesomeversion import AwesomeVersion # type: ignore[import] + +MASTER = "master" + + +class ReleaseType(Enum): + MAJOR = 1 + MINOR = 2 + PATCH = 3 + + +class ReleaseTypeModifier(Enum): + NO = 1 + ALPHA = 2 + BETA = 3 + + +class Branch: + def __init__(self, name): + self.name = name + + @property + def is_dev(self): + return self.name == "dev" + + @property + def is_release(self): + return self.name.startswith("release/") + + +class Git: + + @staticmethod + def get_current_branch() -> Branch: + branch_name = subprocess.check_output(["git", "branch", "--show-current"]) + return Branch(branch_name.decode("utf-8").strip()) + + @staticmethod + def workarea_is_clean() -> bool: + return ( + subprocess.check_output(["git", "status", "--porcelain"]) + .decode("utf-8") + .strip() + == "" + ) + + @staticmethod + def checkout(branch): + subprocess.run(["git", "checkout", branch]) + + @staticmethod + def add_changes(): + subprocess.run(["git", "add", "--all"]) + + @staticmethod + def commit_changes(message): + subprocess.run(["git", "commit", "-m", message]) + + @staticmethod + def pull(): + subprocess.run(["git", "pull"]) + + @staticmethod + def delete_branch(name): + subprocess.run(["git", "branch", "-D", name]) + + @staticmethod + def create_branch(name): + subprocess.run(["git", "branch", name]) + + @staticmethod + def create_tag(name): + subprocess.run(["git", "tag", name]) + + @staticmethod + def push_to_origin(name): + subprocess.run(["git", "push", "origin", name]) + + @staticmethod + def fetch_tags(): + subprocess.run(["git", "fetch", "--tags"]) + + +def menu(title, choices): + while True: + print(title) + for i, choice in enumerate(choices): + print(f" {i + 1} = {choice}") + str_choice = input(": ") + try: + choice = int(str_choice) + except ValueError: + print("Invalid input, please enter a number") + continue + if choice in range(1, len(choices) + 1): + return choices[choice - 1] + else: + print("Invalid input, please enter a valid number") + + +def enum_menu(title, enum_type): + choices = [choice.name for choice in enum_type] + return enum_type[menu(title, choices)] + + +def bump_version( + version, + major: bool = False, + minor: bool = False, + patch: bool = False, + alpha: bool = False, + beta: bool = False, +): + major_number = version.major + minor_number = version.minor + patch_number = version.patch + modifier = version.modifier or "" + + if major: + major_number = int(major_number) + 1 + minor_number = 0 + patch_number = 0 + modifier = "" + elif minor: + minor_number = int(minor_number) + 1 + patch_number = 0 + modifier = "" + elif patch: + patch_number = int(patch_number) + 1 + modifier = "" + + if alpha: + if not modifier: + alpha_version = 0 + elif version.alpha: + if match := re.search(r"\d+$", modifier): + alpha_version = int(match.group()) + 1 + else: + raise ValueError( + "Bumping a non-alpha version (e.g. beta) to an alpha modifier is not supported" + ) + modifier = f"a{alpha_version}" + elif beta: + if not modifier or version.alpha: + beta_version = 0 + elif version.beta: + if match := re.search(r"\d+$", modifier): + beta_version = int(match.group()) + 1 + else: + raise ValueError( + "Bumping a non-beta version (e.g. rc) to a beta modifier is not supported" + ) + modifier = f"b{beta_version}" + + return AwesomeVersion(f"{major_number}.{minor_number}.{patch_number}{modifier}") + + +def get_versions(): + version_tags = subprocess.check_output(["git", "tag", "-l", "v*"]) + + awesome_versions = [] + for version_tag in version_tags.decode("utf-8").split("\n"): + version = AwesomeVersion(version_tag[1:]) + if version.valid: + awesome_versions.append(version) + + awesome_versions.sort() + return awesome_versions + + +def get_integration_name(): + dir_list = [ + name + for name in os.listdir("custom_components") + if os.path.isdir(os.path.join("custom_components", name)) and name != "__pycache__" + ] + if len(dir_list) != 1: + raise ValueError( + f"Expected one directory below custom_components, but found {', '.join(dir_list)}" + ) + return dir_list[0] + + +def update_manifest_version_number(version): + manifest_file = "custom_components/{}/manifest.json".format(get_integration_name()) + + with open(manifest_file) as f: + manifest = json.load(f) + + manifest["version"] = str(version) + with open(manifest_file, "w") as f: + json.dump(manifest, f, indent=2) + + +def get_version_from_manifest(): + manifest_file = "custom_components/{}/manifest.json".format(get_integration_name()) + + with open(manifest_file) as f: + manifest = json.load(f) + + return AwesomeVersion(manifest["version"]) + + +def get_last_released_version(): + versions = get_versions() + logging.debug(f"All versions: {versions}") + + versions = [version for version in versions if not version.modifier] + logging.debug(f"Real versions: {versions}") + + return versions[-1] if len(versions) > 0 else None + + +def main(args): + branch = Git.get_current_branch() + + if not (branch.is_dev or branch.is_release): + raise ValueError( + f"Unexpected branch: {branch.name}, should be dev or release/x.y.z" + ) + + if not Git.workarea_is_clean(): + logging.error("Workarea is not clean") + exit(1) + + Git.fetch_tags() + + manifest_version = get_version_from_manifest() + print(f"Manifest version is {manifest_version}") + + # Alpha and beta modifiers are bumped after release when on a release branch + bump_version_after_release = None + + if branch.is_dev: + last_released_version = get_last_released_version() + print(f"Last released version was {last_released_version}") + + if not last_released_version: + print("First release, nice!") + last_released_version = AwesomeVersion("0.0.0") + + release_type = enum_menu("What type of release is this?", ReleaseType) + release_type_modifier = enum_menu( + "Create releasebranch for alpha or beta?", ReleaseTypeModifier + ) + + next_version = bump_version( + last_released_version, + major=release_type == ReleaseType.MAJOR, + minor=release_type == ReleaseType.MINOR, + patch=release_type == ReleaseType.PATCH, + alpha=release_type_modifier == ReleaseTypeModifier.ALPHA, + beta=release_type_modifier == ReleaseTypeModifier.BETA, + ) + + # Release branch does not have alpha/beta modifiers + release_branch_name = f"release/{AwesomeVersion(f'{next_version.major}.{next_version.minor}.{next_version.patch}')}" + + if branch.is_release: + release_branch_name = branch.name + + # On release branch we can only bump alpha/beta, not major/minor/patch + release_type_modifier = enum_menu( + "Bump alpha or beta (no = release to master)?", ReleaseTypeModifier + ) + + if release_type_modifier == ReleaseTypeModifier.NO: + next_version = AwesomeVersion( + f"{manifest_version.major}.{manifest_version.minor}.{manifest_version.patch}" + ) + else: + next_manifest_version = bump_version( + manifest_version, + alpha=release_type_modifier == ReleaseTypeModifier.ALPHA, + beta=release_type_modifier == ReleaseTypeModifier.BETA, + ) + + # Changing from alpha to beta should bump the version before release + version_diff = next_manifest_version - manifest_version + if version_diff.modifier: + next_version = next_manifest_version + else: + next_version = manifest_version + + # Alpha and beta modifiers are (also) bumped after release + if release_type_modifier != ReleaseTypeModifier.NO: + bump_version_after_release = bump_version( + next_version, + alpha=release_type_modifier == ReleaseTypeModifier.ALPHA, + beta=release_type_modifier == ReleaseTypeModifier.BETA, + ) + + tag_name = f"v{next_version}" + logging.debug(f"Tag name: {tag_name}") + + print(f"On branch: {branch.name}") + print(f"Release branch to use: {release_branch_name}") + if bump_version_after_release: + print(f"Bump version after release: {bump_version_after_release}") + print(" ") + + if input(f"Confirm release of version {next_version}? [y/N]: ") != "y": + exit(1) + + if branch.is_dev: + Git.create_branch(release_branch_name) + Git.checkout(release_branch_name) + + if branch.is_dev or (branch.is_release and not next_version.modifier): + update_manifest_version_number(next_version) + Git.add_changes() + Git.commit_changes(f"Update version to {next_version}") + + if not next_version.modifier: + # Merge to master + Git.checkout(MASTER) + Git.pull() + subprocess.run( + [ + "git", + "merge", + "--no-ff", + release_branch_name, + "--strategy-option", + "theirs", + "-m", + f"Release v{next_version}", + ] + ) + + Git.create_tag(tag_name) + + if bump_version_after_release: + assert Git.get_current_branch() != MASTER + update_manifest_version_number(bump_version_after_release) + Git.add_changes() + Git.commit_changes(f"Update version to {bump_version_after_release}") + + if input("Push to origin? [Y/n]: ") != "n": + if Git.get_current_branch() == MASTER: + Git.push_to_origin(MASTER) + Git.push_to_origin(release_branch_name) + Git.push_to_origin(tag_name) + print("Don't forget to push later or revert changes!") + + + print("Done!") + print(f"Currently on branch: {Git.get_current_branch()}") + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + + parser.add_argument( + "--loglevel", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + default="INFO", + help="Define loglevel, default is INFO.", + ) + args = parser.parse_args() + logging.basicConfig(level=args.loglevel) + + main(args) diff --git a/requirements_dev.txt b/requirements_dev.txt index 2cd61ed..6846386 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,3 +1,4 @@ -r requirements_test.txt +awesomeversion>=24.0.0 homeassistant-stubs==2024.7.0 diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 337695d..daa6589 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -78,7 +78,7 @@ async def test_wifi_strength(hass: HomeAssistant, mock_api): entity = hass.states.get("sensor.name_wifi_quality") assert entity is not None assert entity.state == "fair" - assert entity.attributes["icon"] == "mdi:wifi-strength-2" + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_content_info(hass: HomeAssistant, mock_api): @@ -87,4 +87,3 @@ async def test_content_info(hass: HomeAssistant, mock_api): entity = hass.states.get("sensor.name_content_info") assert entity is not None assert entity.state == "1920 x 1080 @ 60 - SDR" -